Merge branch 'main' into vehikl/make-webhooks-normalized

This commit is contained in:
Vehikl 2025-08-21 15:51:58 -04:00
commit dd2fdd15a1
149 changed files with 4148 additions and 2737 deletions

View File

@ -63,8 +63,8 @@ FROM --platform=$TARGETOS/$TARGETARCH localhost:5000/base-php:$TARGETARCH AS fin
WORKDIR /var/www/html WORKDIR /var/www/html
# Install additional required libraries # Install additional required libraries
RUN apk update && apk add --no-cache \ RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic caddy ca-certificates supervisor supercronic fcgi
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build . COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
@ -85,7 +85,8 @@ RUN chown root:www-data ./ \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \ && ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary # 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 \ && 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 && chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/
# Configure Supervisor # Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf COPY docker/supervisord.conf /etc/supervisord.conf
@ -93,10 +94,11 @@ COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab # Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh ./docker/entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh
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 /bin/ash /healthcheck.sh
EXPOSE 80 443 EXPOSE 80 443
@ -104,5 +106,5 @@ VOLUME /pelican-data
USER www-data USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ] ENTRYPOINT [ "/bin/ash", "/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -67,8 +67,8 @@ FROM --platform=$TARGETOS/$TARGETARCH base AS final
WORKDIR /var/www/html WORKDIR /var/www/html
# Install additional required libraries # Install additional required libraries
RUN apk update && apk add --no-cache \ RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic caddy ca-certificates supervisor supercronic fcgi coreutils
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build . COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
@ -89,7 +89,8 @@ RUN chown root:www-data ./ \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \ && ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary # 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 \ && 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 && chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/
# Configure Supervisor # Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf COPY docker/supervisord.conf /etc/supervisord.conf
@ -97,10 +98,11 @@ COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab # Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh ./docker/entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh
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 /bin/ash /healthcheck.sh
EXPOSE 80 443 EXPOSE 80 443
@ -108,5 +110,5 @@ VOLUME /pelican-data
USER www-data USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ] ENTRYPOINT [ "/bin/ash", "/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -2,10 +2,13 @@
namespace App\Console\Commands\Egg; namespace App\Console\Commands\Egg;
use App\Enums\EggFormat;
use App\Models\Egg; use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService; use App\Services\Eggs\Sharing\EggExporterService;
use Exception; use Exception;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use JsonException;
use Symfony\Component\Yaml\Yaml;
class CheckEggUpdatesCommand extends Command class CheckEggUpdatesCommand extends Command
{ {
@ -23,6 +26,9 @@ class CheckEggUpdatesCommand extends Command
} }
} }
/**
* @throws JsonException
*/
private function check(Egg $egg, EggExporterService $exporterService): void private function check(Egg $egg, EggExporterService $exporterService): void
{ {
if (is_null($egg->update_url)) { if (is_null($egg->update_url)) {
@ -31,22 +37,26 @@ class CheckEggUpdatesCommand extends Command
return; return;
} }
$currentJson = json_decode($exporterService->handle($egg->id)); $ext = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
unset($currentJson->exported_at); $isYaml = in_array($ext, ['yaml', 'yml']);
$updatedEgg = file_get_contents($egg->update_url); $local = $isYaml
assert($updatedEgg !== false); ? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))
$updatedJson = json_decode($updatedEgg); : json_decode($exporterService->handle($egg->id, EggFormat::JSON), true);
unset($updatedJson->exported_at);
if (md5(json_encode($currentJson, JSON_THROW_ON_ERROR)) === md5(json_encode($updatedJson, JSON_THROW_ON_ERROR))) { $remote = file_get_contents($egg->update_url);
$this->info("$egg->name: Up-to-date"); assert($remote !== false);
cache()->put("eggs.$egg->uuid.update", false, now()->addHour());
return; $remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);
}
$this->warn("$egg->name: Found update"); unset($local['exported_at'], $remote['exported_at']);
cache()->put("eggs.$egg->uuid.update", true, now()->addHour());
$localHash = md5(json_encode($local, JSON_THROW_ON_ERROR));
$remoteHash = md5(json_encode($remote, JSON_THROW_ON_ERROR));
$status = $localHash === $remoteHash ? 'Up-to-date' : 'Found update';
$this->{($localHash === $remoteHash) ? 'info' : 'warn'}("$egg->name: $status");
cache()->put("eggs.$egg->uuid.update", $localHash !== $remoteHash, now()->addHour());
} }
} }

View File

@ -32,6 +32,6 @@ enum BackupStatus: string implements HasColor, HasIcon, HasLabel
public function getLabel(): string public function getLabel(): string
{ {
return str($this->value)->headline(); return trans('server/backup.backup_status.' . strtolower($this->value));
} }
} }

View File

@ -68,7 +68,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
public function getLabel(): string public function getLabel(): string
{ {
return str($this->value)->title(); return trans('server/console.status.' . strtolower($this->value));
} }
public function isOffline(): bool public function isOffline(): bool

9
app/Enums/EggFormat.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum EggFormat: string
{
case YAML = 'yaml';
case JSON = 'json';
}

View File

@ -2,9 +2,50 @@
namespace App\Enums; namespace App\Enums;
enum ServerResourceType use App\Models\Server;
enum ServerResourceType: string
{ {
case Unit; case Uptime = 'uptime';
case Percentage; case CPU = 'cpu_absolute';
case Time; case Memory = 'memory_bytes';
case Disk = 'disk_bytes';
case CPULimit = 'cpu';
case MemoryLimit = 'memory';
case DiskLimit = 'disk';
/**
* @return int resource amount in bytes
*/
public function getResourceAmount(Server $server): int
{
if ($this->isLimit()) {
$resourceAmount = $server->{$this->value} ?? 0;
if (!$this->isPercentage()) {
// Our limits are entered as MiB/ MB so we need to convert them to bytes
$resourceAmount *= config('panel.use_binary_prefix') ? 1024 * 1024 : 1000 * 1000;
}
return $resourceAmount;
}
return $server->retrieveResources()[$this->value] ?? 0;
}
public function isLimit(): bool
{
return $this === ServerResourceType::CPULimit || $this === ServerResourceType::MemoryLimit || $this === ServerResourceType::DiskLimit;
}
public function isTime(): bool
{
return $this === ServerResourceType::Uptime;
}
public function isPercentage(): bool
{
return $this === ServerResourceType::CPU || $this === ServerResourceType::CPULimit;
}
} }

View File

@ -8,7 +8,6 @@ use Filament\Support\Contracts\HasLabel;
enum ServerState: string implements HasColor, HasIcon, HasLabel enum ServerState: string implements HasColor, HasIcon, HasLabel
{ {
case Normal = 'normal';
case Installing = 'installing'; case Installing = 'installing';
case InstallFailed = 'install_failed'; case InstallFailed = 'install_failed';
case ReinstallFailed = 'reinstall_failed'; case ReinstallFailed = 'reinstall_failed';
@ -18,7 +17,6 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
public function getIcon(): string public function getIcon(): string
{ {
return match ($this) { return match ($this) {
self::Normal => 'tabler-heart',
self::Installing => 'tabler-heart-bolt', self::Installing => 'tabler-heart-bolt',
self::InstallFailed => 'tabler-heart-x', self::InstallFailed => 'tabler-heart-x',
self::ReinstallFailed => 'tabler-heart-x', self::ReinstallFailed => 'tabler-heart-x',
@ -31,14 +29,13 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
{ {
if ($hex) { if ($hex) {
return match ($this) { return match ($this) {
self::Normal, self::Installing, self::RestoringBackup => '#2563EB', self::Installing, self::RestoringBackup => '#2563EB',
self::Suspended => '#D97706', self::Suspended => '#D97706',
self::InstallFailed, self::ReinstallFailed => '#EF4444', self::InstallFailed, self::ReinstallFailed => '#EF4444',
}; };
} }
return match ($this) { return match ($this) {
self::Normal => 'primary',
self::Installing => 'primary', self::Installing => 'primary',
self::InstallFailed => 'danger', self::InstallFailed => 'danger',
self::ReinstallFailed => 'danger', self::ReinstallFailed => 'danger',

View File

@ -32,4 +32,8 @@ interface OAuthSchemaInterface
public function getHexColor(): ?string; public function getHexColor(): ?string;
public function isEnabled(): bool; public function isEnabled(): bool;
public function shouldCreateMissingUsers(): bool;
public function shouldLinkMissingUsers(): bool;
} }

View File

@ -5,7 +5,9 @@ namespace App\Extensions\OAuth\Schemas;
use App\Extensions\OAuth\OAuthSchemaInterface; use App\Extensions\OAuth\OAuthSchemaInterface;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Set;
use Illuminate\Support\Str; use Illuminate\Support\Str;
abstract class OAuthSchema implements OAuthSchemaInterface abstract class OAuthSchema implements OAuthSchemaInterface
@ -53,6 +55,28 @@ abstract class OAuthSchema implements OAuthSchemaInterface
->revealable() ->revealable()
->autocomplete(false) ->autocomplete(false)
->default(env("OAUTH_{$id}_CLIENT_SECRET")), ->default(env("OAUTH_{$id}_CLIENT_SECRET")),
Toggle::make("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS")
->label(trans('admin/setting.oauth.create_missing_users'))
->columnSpanFull()
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state) => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", (bool) $state))
->default(env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS")),
Toggle::make("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS")
->label(trans('admin/setting.oauth.link_missing_users'))
->columnSpanFull()
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state) => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", (bool) $state))
->default(env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS")),
]; ];
} }
@ -96,4 +120,18 @@ abstract class OAuthSchema implements OAuthSchemaInterface
return env("OAUTH_{$id}_ENABLED", false); return env("OAUTH_{$id}_ENABLED", false);
} }
public function shouldCreateMissingUsers(): bool
{
$id = Str::upper($this->getId());
return env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
}
public function shouldLinkMissingUsers(): bool
{
$id = Str::upper($this->getId());
return env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", false);
}
} }

View File

@ -169,16 +169,6 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->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')
->label(trans('admin/setting.general.navigation'))
->inline()
->options([
false => trans('admin/setting.general.sidebar'),
true => trans('admin/setting.general.topbar'),
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
]), ]),
Group::make() Group::make()
->columns(2) ->columns(2)

View File

@ -29,7 +29,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server'); return !empty(auth()->user()->getCustomization()['top_navigation']) ? false : trans('admin/dashboard.server');
} }
public static function getNavigationLabel(): string public static function getNavigationLabel(): string

View File

@ -207,7 +207,7 @@ class CreateEgg extends CreateRecord
'*' => trans('admin/egg.error_reserved'), '*' => trans('admin/egg.error_reserved'),
]) ])
->required(), ->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value'))->maxLength(255), TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions')) Fieldset::make(trans('admin/egg.user_permissions'))
->schema([ ->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')), Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),

View File

@ -196,7 +196,7 @@ class EditEgg extends EditRecord
'*' => trans('admin/egg.error_reserved'), '*' => trans('admin/egg.error_reserved'),
]) ])
->required(), ->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value'))->maxLength(255), TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions')) Fieldset::make(trans('admin/egg.user_permissions'))
->schema([ ->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')), Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),

View File

@ -40,7 +40,8 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server'); return !empty(auth()->user()->getCustomization()['top_navigation']) ? false : trans('admin/dashboard.server');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -59,7 +59,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return config('panel.filament.top-navigation', false) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user'); return !empty(auth()->user()->getCustomization()['top_navigation']) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -43,7 +43,7 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server'); return !empty(auth()->user()->getCustomization()['top_navigation']) ? false : trans('admin/dashboard.server');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -56,7 +56,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.user'); return !empty(auth()->user()->getCustomization()['top_navigation']) ? false : trans('admin/dashboard.user');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -19,6 +19,7 @@ use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Forms\Get; use Filament\Forms\Get;
@ -129,23 +130,12 @@ class WebhookResource extends Resource
->live() ->live()
->inline() ->inline()
->options(WebhookType::class) ->options(WebhookType::class)
->default(WebhookType::Regular->value) ->default(WebhookType::Regular->value),
->afterStateHydrated(function (string $state) {
if ($state === WebhookType::Discord->value) {
self::sendHelpBanner();
}
})
->afterStateUpdated(function (string $state) {
if ($state === WebhookType::Discord->value) {
self::sendHelpBanner();
}
}),
TextInput::make('description') TextInput::make('description')
->label(trans('admin/webhook.description')) ->label(trans('admin/webhook.description'))
->required(), ->required(),
TextInput::make('endpoint') TextInput::make('endpoint')
->label(trans('admin/webhook.endpoint')) ->label(trans('admin/webhook.endpoint'))
->activeUrl()
->required() ->required()
->columnSpanFull() ->columnSpanFull()
->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value)), ->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value)),
@ -153,6 +143,15 @@ class WebhookResource extends Resource
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord->value) ->hidden(fn (Get $get) => $get('type') === WebhookType::Discord->value)
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->schema(fn () => self::getRegularFields()) ->schema(fn () => self::getRegularFields())
->headerActions([
Action::make('reset_headers')
->label(trans('admin/webhook.reset_headers'))
->color('danger')
->icon('heroicon-o-trash')
->action(fn (Get $get, Set $set) => $set('headers', [
'X-Webhook-Event' => '{{event}}',
])),
])
->formBefore(), ->formBefore(),
Section::make(trans('admin/webhook.discord')) Section::make(trans('admin/webhook.discord'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular->value) ->hidden(fn (Get $get) => $get('type') === WebhookType::Regular->value)
@ -163,8 +162,6 @@ class WebhookResource extends Resource
->aside() ->aside()
->formBefore(), ->formBefore(),
Section::make(trans('admin/webhook.events')) Section::make(trans('admin/webhook.events'))
->collapsible()
->collapsed(fn (Get $get) => count($get('events') ?? []))
->schema([ ->schema([
CheckboxList::make('events') CheckboxList::make('events')
->live() ->live()
@ -183,7 +180,10 @@ class WebhookResource extends Resource
{ {
return [ return [
KeyValue::make('headers') KeyValue::make('headers')
->label(trans('admin/webhook.headers')), ->label(trans('admin/webhook.headers'))
->default(fn () => [
'X-Webhook-Event' => '{{event}}',
]),
]; ];
} }

View File

@ -63,4 +63,15 @@ class CreateWebhookConfiguration extends CreateRecord
return $data; return $data;
} }
protected function getRedirectUrl(): string
{
return EditWebhookConfiguration::getUrl(['record' => $this->getRecord()]);
}
public function mount(): void
{
parent::mount();
WebhookResource::sendHelpBanner();
}
} }

View File

@ -123,4 +123,10 @@ class EditWebhookConfiguration extends EditRecord
{ {
$this->dispatch('refresh-widget'); $this->dispatch('refresh-widget');
} }
public function mount(int|string $record): void
{
parent::mount($record);
WebhookResource::sendHelpBanner();
}
} }

View File

@ -63,7 +63,7 @@ class ListServers extends ListRecords
TextColumn::make('condition') TextColumn::make('condition')
->label('Status') ->label('Status')
->badge() ->badge()
->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time)) ->tooltip(fn (Server $server) => $server->formatResource(ServerResourceType::Uptime))
->icon(fn (Server $server) => $server->condition->getIcon()) ->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor()), ->color(fn (Server $server) => $server->condition->getColor()),
TextColumn::make('name') TextColumn::make('name')
@ -78,22 +78,22 @@ class ListServers extends ListRecords
->copyable(request()->isSecure()) ->copyable(request()->isSecure())
->state(fn (Server $server) => $server->allocation->address ?? 'None'), ->state(fn (Server $server) => $server->allocation->address ?? 'None'),
TextColumn::make('cpuUsage') TextColumn::make('cpuUsage')
->label('Resources') ->label(trans('server/dashboard.resources'))
->icon('tabler-cpu') ->icon('tabler-cpu')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('cpu', limit: true, type: ServerResourceType::Percentage, precision: 0)) ->tooltip(fn (Server $server) => trans('server/dashboard.usage_limit', ['resource' => $server->formatResource(ServerResourceType::CPULimit)]))
->state(fn (Server $server) => $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage)) ->state(fn (Server $server) => $server->formatResource(ServerResourceType::CPU))
->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')), ->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')),
TextColumn::make('memoryUsage') TextColumn::make('memoryUsage')
->label('') ->label('')
->icon('tabler-device-desktop-analytics') ->icon('tabler-device-desktop-analytics')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('memory', limit: true)) ->tooltip(fn (Server $server) => trans('server/dashboard.usage_limit', ['resource' => $server->formatResource(ServerResourceType::MemoryLimit)]))
->state(fn (Server $server) => $server->formatResource('memory_bytes')) ->state(fn (Server $server) => $server->formatResource(ServerResourceType::Memory))
->color(fn (Server $server) => $this->getResourceColor($server, 'memory')), ->color(fn (Server $server) => $this->getResourceColor($server, 'memory')),
TextColumn::make('diskUsage') TextColumn::make('diskUsage')
->label('') ->label('')
->icon('tabler-device-sd-card') ->icon('tabler-device-sd-card')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('disk', limit: true)) ->tooltip(fn (Server $server) => trans('server/dashboard.usage_limit', ['resource' => $server->formatResource(ServerResourceType::DiskLimit)]))
->state(fn (Server $server) => $server->formatResource('disk_bytes')) ->state(fn (Server $server) => $server->formatResource(ServerResourceType::Disk))
->color(fn (Server $server) => $this->getResourceColor($server, 'disk')), ->color(fn (Server $server) => $this->getResourceColor($server, 'disk')),
]; ];
} }

View File

@ -2,9 +2,12 @@
namespace App\Filament\Components\Actions; namespace App\Filament\Components\Actions;
use App\Enums\EggFormat;
use App\Models\Egg; use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService; use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Support\Enums\Alignment;
class ExportEggAction extends Action class ExportEggAction extends Action
{ {
@ -21,8 +24,30 @@ class ExportEggAction extends Action
$this->authorize(fn () => auth()->user()->can('export egg')); $this->authorize(fn () => auth()->user()->can('export egg'));
$this->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { $this->modalHeading(fn (Egg $egg) => trans('filament-actions::export.modal.actions.export.label') . ' ' . $egg->name);
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')); $this->modalIcon($this->icon);
$this->form([
Placeholder::make('')
->label(fn (Egg $egg) => trans('admin/egg.export.modal', ['egg' => $egg->name])),
]);
$this->modalFooterActionsAlignment(Alignment::Center);
$this->modalFooterActions([
Action::make('json')
->label(trans('admin/egg.export.as') . ' .json')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::JSON);
}, 'egg-' . $egg->getKebabName() . '.json'))
->close(),
Action::make('yaml')
->label(trans('admin/egg.export.as') . ' .yaml')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::YAML);
}, 'egg-' . $egg->getKebabName() . '.yaml'))
->close(),
]);
} }
} }

View File

@ -47,13 +47,31 @@ class ImportEggAction extends Action
foreach ($eggs as $egg) { foreach ($eggs as $egg) {
if ($egg instanceof TemporaryUploadedFile) { if ($egg instanceof TemporaryUploadedFile) {
$name = str($egg->getClientOriginalName())->afterLast('egg-')->before('.json')->headline(); $originalName = $egg->getClientOriginalName();
$filename = str($originalName)->afterLast('egg-');
$ext = str($originalName)->afterLast('.')->lower()->toString();
$name = match ($ext) {
'json' => $filename->before('.json')->headline(),
'yaml' => $filename->before('.yaml')->headline(),
'yml' => $filename->before('.yml')->headline(),
default => $filename->headline(),
};
$method = 'fromFile'; $method = 'fromFile';
} else { } else {
$egg = str($egg); $egg = str($egg);
$egg = $egg->contains('github.com') ? $egg->replaceFirst('blob', 'raw') : $egg; $egg = $egg->contains('github.com') ? $egg->replaceFirst('blob', 'raw') : $egg;
$name = $egg->afterLast('/egg-')->before('.json')->headline();
$method = 'fromUrl'; $method = 'fromUrl';
$filename = $egg->afterLast('/egg-');
$ext = $filename->afterLast('.')->lower()->toString();
$name = match ($ext) {
'json' => $filename->before('.json')->headline(),
'yaml' => $filename->before('.yaml')->headline(),
'yml' => $filename->before('.yml')->headline(),
default => $filename->headline(),
};
} }
try { try {
$eggImportService->$method($egg); $eggImportService->$method($egg);
@ -94,7 +112,7 @@ class ImportEggAction extends Action
FileUpload::make('files') FileUpload::make('files')
->label(trans('admin/egg.model_label')) ->label(trans('admin/egg.model_label'))
->hint(trans('admin/egg.import.egg_help')) ->hint(trans('admin/egg.import.egg_help'))
->acceptedFileTypes(['application/json']) ->acceptedFileTypes(['application/json', 'application/yaml', 'application/x-yaml', 'text/yaml'])
->preserveFilenames() ->preserveFilenames()
->previewable(false) ->previewable(false)
->storeFiles(false) ->storeFiles(false)
@ -125,7 +143,7 @@ class ImportEggAction extends Action
}), }),
Repeater::make('urls') Repeater::make('urls')
->label('') ->label('')
->itemLabel(fn (array $state) => str($state['url'])->afterLast('/egg-')->before('.json')->headline()) ->itemLabel(fn (array $state) => str($state['url'])->afterLast('/egg-')->beforeLast('.')->headline())
->hint(trans('admin/egg.import.url_help')) ->hint(trans('admin/egg.import.url_help'))
->addActionLabel(trans('admin/egg.import.add_url')) ->addActionLabel(trans('admin/egg.import.add_url'))
->grid($isMultiple ? 2 : null) ->grid($isMultiple ? 2 : null)
@ -139,7 +157,7 @@ class ImportEggAction extends Action
->label(trans('admin/egg.import.url')) ->label(trans('admin/egg.import.url'))
->placeholder('https://github.com/pelican-eggs/generic/blob/main/nodejs/egg-node-js-generic.json') ->placeholder('https://github.com/pelican-eggs/generic/blob/main/nodejs/egg-node-js-generic.json')
->url() ->url()
->endsWith('.json') ->endsWith(['.json', '.yaml', '.yml'])
->validationAttribute(trans('admin/egg.import.url')), ->validationAttribute(trans('admin/egg.import.url')),
]), ]),
]), ]),

View File

@ -39,26 +39,26 @@ class ImportScheduleAction extends Action
Tabs::make('Tabs') Tabs::make('Tabs')
->contained(false) ->contained(false)
->tabs([ ->tabs([
Tab::make(trans('admin/schedule.import.file')) Tab::make(trans('server/schedule.import_action.file'))
->icon('tabler-file-upload') ->icon('tabler-file-upload')
->schema([ ->schema([
FileUpload::make('files') FileUpload::make('files')
->label(trans('admin/schedule.model_label')) ->hiddenLabel()
->hint(trans('admin/schedule.import.schedule_help')) ->hint(trans('server/schedule.import_action.schedule_help'))
->acceptedFileTypes(['application/json']) ->acceptedFileTypes(['application/json'])
->preserveFilenames() ->preserveFilenames()
->previewable(false) ->previewable(false)
->storeFiles(false) ->storeFiles(false)
->multiple(true), ->multiple(true),
]), ]),
Tab::make(trans('admin/schedule.import.url')) Tab::make(trans('server/schedule.import_action.url'))
->icon('tabler-world-upload') ->icon('tabler-world-upload')
->schema([ ->schema([
Repeater::make('urls') Repeater::make('urls')
->label('') ->hiddenLabel()
->itemLabel(fn (array $state) => str($state['url'])->afterLast('/schedule-')->before('.json')->headline()) ->itemLabel(fn (array $state) => str($state['url'])->afterLast('/schedule-')->before('.json')->headline())
->hint(trans('admin/schedule.import.url_help')) ->hint(trans('server/schedule.import_action.url_help'))
->addActionLabel(trans('admin/schedule.import.add_url')) ->addActionLabel(trans('server/schedule.import_action.add_url'))
->grid(2) ->grid(2)
->reorderable(false) ->reorderable(false)
->addable(true) ->addable(true)
@ -66,10 +66,10 @@ class ImportScheduleAction extends Action
->schema([ ->schema([
TextInput::make('url') TextInput::make('url')
->live() ->live()
->label(trans('admin/schedule.import.url')) ->label(trans('server/schedule.import_action.url'))
->url() ->url()
->endsWith('.json') ->endsWith('.json')
->validationAttribute(trans('admin/schedule.import.url')), ->validationAttribute(trans('server/schedule.import_action.url')),
]), ]),
]), ]),
]), ]),
@ -104,14 +104,14 @@ class ImportScheduleAction extends Action
if ($failed->count() > 0) { if ($failed->count() > 0) {
Notification::make() Notification::make()
->title(trans('admin/schedule.import.import_failed')) ->title(trans('server/schedule.import_action.import_failed'))
->body($failed->join(', ')) ->body($failed->join(', '))
->danger() ->danger()
->send(); ->send();
} }
if ($success->count() > 0) { if ($success->count() > 0) {
Notification::make() Notification::make()
->title(trans('admin/schedule.import.import_success')) ->title(trans('server/schedule.import_action.import_success'))
->body($success->join(', ')) ->body($success->join(', '))
->success() ->success()
->send(); ->send();

View File

@ -15,6 +15,11 @@ class PreviewStartupAction extends Action
return 'preview'; return 'preview';
} }
public function getLabel(): string
{
return trans('server/startup.preview');
}
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@ -2,8 +2,11 @@
namespace App\Filament\Components\Tables\Actions; namespace App\Filament\Components\Tables\Actions;
use App\Enums\EggFormat;
use App\Models\Egg; use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService; use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Forms\Components\Placeholder;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
class ExportEggAction extends Action class ExportEggAction extends Action
@ -23,8 +26,30 @@ class ExportEggAction extends Action
$this->authorize(fn () => auth()->user()->can('export egg')); $this->authorize(fn () => auth()->user()->can('export egg'));
$this->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { $this->modalHeading(fn (Egg $egg) => trans('filament-actions::export.modal.actions.export.label') . ' ' . $egg->name);
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')); $this->modalIcon($this->icon);
$this->form([
Placeholder::make('')
->label(fn (Egg $egg) => trans('admin/egg.export.modal', ['egg' => $egg->name])),
]);
$this->modalFooterActionsAlignment(Alignment::Center);
$this->modalFooterActions([
Action::make('json')
->label(trans('admin/egg.export.as') . ' .json')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::JSON);
}, 'egg-' . $egg->getKebabName() . '.json'))
->close(),
Action::make('yaml')
->label(trans('admin/egg.export.as') . ' .yaml')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::YAML);
}, 'egg-' . $egg->getKebabName() . '.yaml'))
->close(),
]);
} }
} }

View File

@ -117,7 +117,7 @@ class ImportEggAction extends Action
} }
}), }),
Repeater::make('urls') Repeater::make('urls')
->itemLabel(fn (array $state) => str($state['url'])->afterLast('/egg-')->before('.json')->headline()) ->itemLabel(fn (array $state) => str($state['url'])->afterLast('/egg-')->beforeLast('.')->headline())
->hint(trans('admin/egg.import.url_help')) ->hint(trans('admin/egg.import.url_help'))
->addActionLabel(trans('admin/egg.import.add_url')) ->addActionLabel(trans('admin/egg.import.add_url'))
->grid($isMultiple ? 2 : null) ->grid($isMultiple ? 2 : null)
@ -131,7 +131,7 @@ class ImportEggAction extends Action
->label(trans('admin/egg.import.url')) ->label(trans('admin/egg.import.url'))
->placeholder('https://github.com/pelican-eggs/generic/blob/main/nodejs/egg-node-js-generic.json') ->placeholder('https://github.com/pelican-eggs/generic/blob/main/nodejs/egg-node-js-generic.json')
->url() ->url()
->endsWith('.json') ->endsWith(['.json', '.yaml', '.yml'])
->validationAttribute(trans('admin/egg.import.url')), ->validationAttribute(trans('admin/egg.import.url')),
]), ]),
]), ]),

View File

@ -174,7 +174,7 @@ class EditProfile extends BaseEditProfile
$unlink = array_key_exists($id, $this->getUser()->oauth ?? []); $unlink = array_key_exists($id, $this->getUser()->oauth ?? []);
$actions[] = Action::make("oauth_$id") $actions[] = Action::make("oauth_$id")
->label(($unlink ? trans('profile.unlink') : trans('profile.link')) . $name) ->label(trans('profile.' . ($unlink ? 'unlink' : 'link'), ['name' => $name]))
->icon($unlink ? 'tabler-unlink' : 'tabler-link') ->icon($unlink ? 'tabler-unlink' : 'tabler-link')
->color(Color::hex($schema->getHexColor())) ->color(Color::hex($schema->getHexColor()))
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) { ->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
@ -322,6 +322,7 @@ class EditProfile extends BaseEditProfile
Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([ Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([
Repeater::make('api_keys') Repeater::make('api_keys')
->hiddenLabel() ->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys') ->relationship('apiKeys')
->addable(false) ->addable(false)
->itemLabel(fn ($state) => $state['identifier']) ->itemLabel(fn ($state) => $state['identifier'])
@ -406,6 +407,7 @@ class EditProfile extends BaseEditProfile
Section::make(trans('profile.ssh_keys'))->columnSpan(2)->schema([ Section::make(trans('profile.ssh_keys'))->columnSpan(2)->schema([
Repeater::make('ssh_keys') Repeater::make('ssh_keys')
->hiddenLabel() ->hiddenLabel()
->inlineLabel(false)
->relationship('sshKeys') ->relationship('sshKeys')
->addable(false) ->addable(false)
->itemLabel(fn ($state) => $state['name']) ->itemLabel(fn ($state) => $state['name'])
@ -445,14 +447,17 @@ class EditProfile extends BaseEditProfile
->icon('tabler-history') ->icon('tabler-history')
->schema([ ->schema([
Repeater::make('activity') Repeater::make('activity')
->label('') ->hiddenLabel()
->inlineLabel(false)
->deletable(false) ->deletable(false)
->addable(false) ->addable(false)
->relationship(null, function (Builder $query) { ->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc'); $query->orderBy('timestamp', 'desc');
}) })
->schema([ ->schema([
Placeholder::make('activity!')->label('')->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())), Placeholder::make('log')
->hiddenLabel()
->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]), ]),
]), ]),
@ -471,6 +476,14 @@ class EditProfile extends BaseEditProfile
'grid' => trans('profile.grid'), 'grid' => trans('profile.grid'),
'table' => trans('profile.table'), 'table' => trans('profile.table'),
]), ]),
ToggleButtons::make('top_navigation')
->label(trans('profile.navigation'))
->inline()
->required()
->options([
true => trans('profile.top'),
false => trans('profile.side'),
]),
]), ]),
Section::make(trans('profile.console')) Section::make(trans('profile.console'))
->collapsible() ->collapsible()
@ -628,9 +641,10 @@ class EditProfile extends BaseEditProfile
'console_rows' => $data['console_rows'], 'console_rows' => $data['console_rows'],
'console_graph_period' => $data['console_graph_period'], 'console_graph_period' => $data['console_graph_period'],
'dashboard_layout' => $data['dashboard_layout'], 'dashboard_layout' => $data['dashboard_layout'],
'top_navigation' => $data['top_navigation'],
]; ];
unset($data['console_font'],$data['console_font_size'], $data['console_rows'], $data['dashboard_layout']); unset($data['console_font'],$data['console_font_size'], $data['console_rows'], $data['dashboard_layout'], $data['top_navigation']);
$data['customization'] = json_encode($moarbetterdata); $data['customization'] = json_encode($moarbetterdata);
return $data; return $data;
@ -645,6 +659,7 @@ class EditProfile extends BaseEditProfile
$data['console_rows'] = $moarbetterdata['console_rows'] ?? 30; $data['console_rows'] = $moarbetterdata['console_rows'] ?? 30;
$data['console_graph_period'] = $moarbetterdata['console_graph_period'] ?? 30; $data['console_graph_period'] = $moarbetterdata['console_graph_period'] ?? 30;
$data['dashboard_layout'] = $moarbetterdata['dashboard_layout'] ?? 'grid'; $data['dashboard_layout'] = $moarbetterdata['dashboard_layout'] ?? 'grid';
$data['top_navigation'] = $moarbetterdata['top_navigation'] ?? false;
return $data; return $data;
} }

View File

@ -162,6 +162,7 @@ class Console extends Page
return [ return [
Action::make('start') Action::make('start')
->label(trans('server/console.power_actions.start'))
->color('primary') ->color('primary')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'start', 'uuid' => $server->uuid]) ->dispatch('setServerState', ['state' => 'start', 'uuid' => $server->uuid])
@ -169,6 +170,7 @@ class Console extends Page
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()) ->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable())
->icon('tabler-player-play-filled'), ->icon('tabler-player-play-filled'),
Action::make('restart') Action::make('restart')
->label(trans('server/console.power_actions.restart'))
->color('gray') ->color('gray')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'restart', 'uuid' => $server->uuid]) ->dispatch('setServerState', ['state' => 'restart', 'uuid' => $server->uuid])
@ -176,6 +178,7 @@ class Console extends Page
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()) ->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable())
->icon('tabler-reload'), ->icon('tabler-reload'),
Action::make('stop') Action::make('stop')
->label(trans('server/console.power_actions.stop'))
->color('danger') ->color('danger')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'stop', 'uuid' => $server->uuid]) ->dispatch('setServerState', ['state' => 'stop', 'uuid' => $server->uuid])
@ -184,8 +187,9 @@ class Console extends Page
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()) ->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable())
->icon('tabler-player-stop-filled'), ->icon('tabler-player-stop-filled'),
Action::make('kill') Action::make('kill')
->label(trans('server/console.power_actions.kill'))
->color('danger') ->color('danger')
->tooltip('This can result in data corruption and/or data loss!') ->tooltip(trans('server/console.power_actions.kill_tooltip'))
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'kill', 'uuid' => $server->uuid]) ->dispatch('setServerState', ['state' => 'kill', 'uuid' => $server->uuid])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
@ -193,4 +197,14 @@ class Console extends Page
->icon('tabler-alert-square'), ->icon('tabler-alert-square'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/console.title');
}
public function getTitle(): string
{
return trans('server/console.title');
}
} }

View File

@ -39,7 +39,7 @@ class Settings extends ServerFormPage
'lg' => 6, 'lg' => 6,
]) ])
->schema([ ->schema([
Section::make('Server Information') Section::make(trans('server/setting.server_info.title'))
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
@ -47,11 +47,11 @@ class Settings extends ServerFormPage
'lg' => 6, 'lg' => 6,
]) ])
->schema([ ->schema([
Fieldset::make('Server') Fieldset::make()
->label('Information') ->label(trans('server/setting.server_info.information'))
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label('Server Name') ->label(trans('server/setting.server_info.name'))
->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->required() ->required()
->columnSpan([ ->columnSpan([
@ -63,7 +63,7 @@ class Settings extends ServerFormPage
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateName($state, $server)), ->afterStateUpdated(fn ($state, Server $server) => $this->updateName($state, $server)),
Textarea::make('description') Textarea::make('description')
->label('Server Description') ->label(trans('server/setting.server_info.description'))
->hidden(!config('panel.editable_server_descriptions')) ->hidden(!config('panel.editable_server_descriptions'))
->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->columnSpan([ ->columnSpan([
@ -76,7 +76,7 @@ class Settings extends ServerFormPage
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)), ->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)),
TextInput::make('uuid') TextInput::make('uuid')
->label('Server UUID') ->label(trans('server/setting.server_info.uuid'))
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
@ -85,12 +85,12 @@ class Settings extends ServerFormPage
]) ])
->disabled(), ->disabled(),
TextInput::make('id') TextInput::make('id')
->label('Server ID') ->label(trans('server/setting.server_info.id'))
->disabled() ->disabled()
->columnSpan(1), ->columnSpan(1),
]), ]),
Fieldset::make('Limits') Fieldset::make()
->label('Limits') ->label(trans('server/setting.server_info.limits.title'))
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
@ -100,57 +100,56 @@ class Settings extends ServerFormPage
->schema([ ->schema([
TextInput::make('cpu') TextInput::make('cpu')
->label('') ->label('')
->prefix('CPU') ->prefix(trans('server/setting.server_info.limits.cpu'))
->prefixIcon('tabler-cpu') ->prefixIcon('tabler-cpu')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : Number::format($server->cpu, locale: auth()->user()->language) . '%'), ->formatStateUsing(fn ($state, Server $server) => !$state ? trans('server/setting.server_info.limits.unlimited') : Number::format($server->cpu, locale: auth()->user()->language) . '%'),
TextInput::make('memory') TextInput::make('memory')
->label('') ->label('')
->prefix('Memory') ->prefix(trans('server/setting.server_info.limits.memory'))
->prefixIcon('tabler-device-desktop-analytics') ->prefixIcon('tabler-device-desktop-analytics')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : convert_bytes_to_readable($server->memory * 2 ** 20)), ->formatStateUsing(fn ($state, Server $server) => !$state ? trans('server/setting.server_info.limits.unlimited') : convert_bytes_to_readable($server->memory * 2 ** 20)),
TextInput::make('disk') TextInput::make('disk')
->label('') ->label('')
->prefix('Disk Space') ->prefix(trans('server/setting.server_info.limits.disk'))
->prefixIcon('tabler-device-sd-card') ->prefixIcon('tabler-device-sd-card')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : convert_bytes_to_readable($server->disk * 2 ** 20)), ->formatStateUsing(fn ($state, Server $server) => !$state ? trans('server/setting.server_info.limits.unlimited') : convert_bytes_to_readable($server->disk * 2 ** 20)),
TextInput::make('backup_limit') TextInput::make('backup_limit')
->label('') ->label('')
->prefix('Backups') ->prefix(trans('server/setting.server_info.limits.backups'))
->prefixIcon('tabler-file-zip') ->prefixIcon('tabler-file-zip')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Backups' : $server->backups->count() . ' of ' . $state), ->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Backups' : $server->backups->count() . ' ' .trans('server/setting.server_info.limits.of') . ' ' . $state),
TextInput::make('database_limit') TextInput::make('database_limit')
->label('') ->label('')
->prefix('Databases') ->prefix(trans('server/setting.server_info.limits.databases'))
->prefixIcon('tabler-database') ->prefixIcon('tabler-database')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Databases' : $server->databases->count() . ' of ' . $state), ->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Databases' : $server->databases->count() . ' ' . trans('server/setting.server_info.limits.of') . ' ' .$state),
TextInput::make('allocation_limit') TextInput::make('allocation_limit')
->label('') ->label('')
->prefix('Allocations') ->prefix(trans('server/setting.server_info.limits.allocations'))
->prefixIcon('tabler-network') ->prefixIcon('tabler-network')
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Additional Allocations' : $server->allocations->count() . ' of ' . $state), ->formatStateUsing(fn ($state, Server $server) => !$state ? trans('server/setting.server_info.limits.no_allocations') : $server->allocations->count() . ' ' .trans('server/setting.server_info.limits.of') . ' ' . $state),
]), ]),
]), ]),
Section::make('Node Information') Section::make(trans('server/setting.node_info.title'))
->schema([ ->schema([
TextInput::make('node.name') TextInput::make('node.name')
->label('Node Name') ->label(trans('server/setting.node_info.name'))
->formatStateUsing(fn (Server $server) => $server->node->name) ->formatStateUsing(fn (Server $server) => $server->node->name)
->disabled(), ->disabled(),
Fieldset::make('SFTP Information') Fieldset::make(trans('server/setting.node_info.sftp.title'))
->hidden(fn () => !auth()->user()->can(Permission::ACTION_FILE_SFTP, $server)) ->hidden(fn () => !auth()->user()->can(Permission::ACTION_FILE_SFTP, $server))
->label('SFTP Information')
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
@ -159,13 +158,13 @@ class Settings extends ServerFormPage
]) ])
->schema([ ->schema([
TextInput::make('connection') TextInput::make('connection')
->label('Connection') ->label(trans('server/setting.node_info.sftp.connection'))
->columnSpan(1) ->columnSpan(1)
->disabled() ->disabled()
->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null) ->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->hintAction( ->hintAction(
Action::make('connect_sftp') Action::make('connect_sftp')
->label('Connect to SFTP') ->label(trans('server/setting.node_info.sftp.action'))
->color('success') ->color('success')
->icon('tabler-plug') ->icon('tabler-plug')
->url(function (Server $server) { ->url(function (Server $server) {
@ -180,28 +179,29 @@ class Settings extends ServerFormPage
return 'sftp://' . auth()->user()->username . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp; return 'sftp://' . auth()->user()->username . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
}), }),
TextInput::make('username') TextInput::make('username')
->label('Username') ->label(trans('server/setting.node_info.sftp.username'))
->columnSpan(1) ->columnSpan(1)
->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null) ->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->disabled() ->disabled()
->formatStateUsing(fn (Server $server) => auth()->user()->username . '.' . $server->uuid_short), ->formatStateUsing(fn (Server $server) => auth()->user()->username . '.' . $server->uuid_short),
Placeholder::make('password') Placeholder::make('password')
->label(trans('server/setting.node_info.sftp.password'))
->columnSpan(1) ->columnSpan(1)
->content('Your SFTP password is the same as the password you use to access this panel.'), ->content(trans('server/setting.node_info.sftp.password_body')),
]), ]),
]), ]),
Section::make('Reinstall Server') Section::make(trans('server/setting.reinstall.title'))
->hidden(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server)) ->hidden(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->collapsible() ->collapsible()
->footerActions([ ->footerActions([
Action::make('reinstall') Action::make('reinstall')
->label(trans('server/setting.reinstall.action'))
->color('danger') ->color('danger')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->label('Reinstall')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Are you sure you want to reinstall the server?') ->modalHeading(trans('server/setting.reinstall.modal'))
->modalDescription('Some files may be deleted or modified during this process, please back up your data before continuing.') ->modalDescription(trans('server/setting.reinstall.modal_description'))
->modalSubmitActionLabel('Yes, Reinstall') ->modalSubmitActionLabel(trans('server/setting.reinstall.yes'))
->action(function (Server $server, ReinstallServerService $reinstallService) { ->action(function (Server $server, ReinstallServerService $reinstallService) {
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403); abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403);
@ -211,9 +211,9 @@ class Settings extends ServerFormPage
report($exception); report($exception);
Notification::make() Notification::make()
->danger() ->title(trans('server/setting.reinstall.notification_fail'))
->title('Server Reinstall failed')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger()
->send(); ->send();
return; return;
@ -223,8 +223,8 @@ class Settings extends ServerFormPage
->log(); ->log();
Notification::make() Notification::make()
->title(trans('server/setting.reinstall.notification_start'))
->success() ->success()
->title('Server Reinstall started')
->send(); ->send();
redirect(Console::getUrl()); redirect(Console::getUrl());
@ -233,9 +233,9 @@ class Settings extends ServerFormPage
->footerActionsAlignment(Alignment::Right) ->footerActionsAlignment(Alignment::Right)
->schema([ ->schema([
Placeholder::make('') Placeholder::make('')
->label('Reinstalling your server will stop it, and then re-run the installation script that initially set it up.'), ->label(trans('server/setting.reinstall.body')),
Placeholder::make('') Placeholder::make('')
->label('Some files may be deleted or modified during this process, please back up your data before continuing.'), ->label(trans('server/setting.reinstall.body2')),
]), ]),
]); ]);
} }
@ -258,15 +258,15 @@ class Settings extends ServerFormPage
} }
Notification::make() Notification::make()
->success() ->title(trans('server/setting.notification_name'))
->title('Updated Server Name')
->body(fn () => $original . ' -> ' . $name) ->body(fn () => $original . ' -> ' . $name)
->success()
->send(); ->send();
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->danger() ->title(trans('server/setting.failed'))
->title('Failed')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger()
->send(); ->send();
} }
} }
@ -289,16 +289,26 @@ class Settings extends ServerFormPage
} }
Notification::make() Notification::make()
->success() ->title(trans('server/setting.notification_description'))
->title('Updated Server Description')
->body(fn () => $original . ' -> ' . $description) ->body(fn () => $original . ' -> ' . $description)
->success()
->send(); ->send();
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->danger() ->title(trans('server/setting.failed'))
->title('Failed')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger()
->send(); ->send();
} }
} }
public function getTitle(): string
{
return trans('server/setting.title');
}
public static function getNavigationLabel(): string
{
return trans('server/setting.title');
}
} }

View File

@ -18,6 +18,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -43,7 +44,7 @@ class Startup extends ServerFormPage
Hidden::make('previewing') Hidden::make('previewing')
->default(false), ->default(false),
Textarea::make('startup') Textarea::make('startup')
->label('Startup Command') ->label(trans('server/startup.command'))
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
@ -51,10 +52,10 @@ class Startup extends ServerFormPage
'lg' => 4, 'lg' => 4,
]) ])
->autosize() ->autosize()
->hintAction(PreviewStartupAction::make('preview')) ->hintAction(PreviewStartupAction::make())
->readOnly(), ->readOnly(),
TextInput::make('custom_image') TextInput::make('custom_image')
->label('Docker Image') ->label(trans('server/startup.docker_image'))
->readOnly() ->readOnly()
->visible(fn (Server $server) => !in_array($server->image, $server->egg->docker_images)) ->visible(fn (Server $server) => !in_array($server->image, $server->egg->docker_images))
->formatStateUsing(fn (Server $server) => $server->image) ->formatStateUsing(fn (Server $server) => $server->image)
@ -65,7 +66,7 @@ class Startup extends ServerFormPage
'lg' => 2, 'lg' => 2,
]), ]),
Select::make('image') Select::make('image')
->label('Docker Image') ->label(trans('server/startup.docker_image'))
->live() ->live()
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images)) ->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
@ -80,8 +81,8 @@ class Startup extends ServerFormPage
} }
Notification::make() Notification::make()
->title('Docker image updated') ->title(trans('server/startup.notification_docker'))
->body('Restart the server to use the new image.') ->body(trans('server/startup.notification_docker_body'))
->success() ->success()
->send(); ->send();
}) })
@ -97,10 +98,10 @@ class Startup extends ServerFormPage
'md' => 2, 'md' => 2,
'lg' => 2, 'lg' => 2,
]), ]),
Section::make('Server Variables') Section::make(trans('server/startup.variables'))
->schema([ ->schema([
Repeater::make('server_variables') Repeater::make('server_variables')
->label('') ->hiddenLabel()
->relationship('serverVariables', fn (Builder $query) => $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort')) ->relationship('serverVariables', fn (Builder $query) => $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort'))
->grid() ->grid()
->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
@ -207,9 +208,9 @@ class Startup extends ServerFormPage
if ($validator->fails()) { if ($validator->fails()) {
Notification::make() Notification::make()
->danger() ->title(trans('server/startup.validation_fail', ['variable' => $serverVariable->variable->name]))
->title('Validation Failed: ' . $serverVariable->variable->name)
->body(implode(', ', $validator->errors()->all())) ->body(implode(', ', $validator->errors()->all()))
->danger()
->send(); ->send();
return null; return null;
@ -232,18 +233,28 @@ class Startup extends ServerFormPage
->log(); ->log();
} }
Notification::make() Notification::make()
->success() ->title(trans('server/startup.update', ['variable' => $serverVariable->variable->name]))
->title('Updated: ' . $serverVariable->variable->name)
->body(fn () => $original . ' -> ' . $state) ->body(fn () => $original . ' -> ' . $state)
->success()
->send(); ->send();
} catch (\Exception $e) { } catch (\Exception $e) {
Notification::make() Notification::make()
->danger() ->title(trans('server/startup.fail', ['variable' => $serverVariable->variable->name]))
->title('Failed: ' . $serverVariable->variable->name)
->body($e->getMessage()) ->body($e->getMessage())
->danger()
->send(); ->send();
} }
return null; return null;
} }
public function getTitle(): string|Htmlable
{
return trans('server/startup.title');
}
public static function getNavigationLabel(): string
{
return trans('server/startup.title');
}
} }

View File

@ -38,10 +38,6 @@ class ActivityResource extends Resource
protected static ?string $model = ActivityLog::class; protected static ?string $model = ActivityLog::class;
protected static ?string $modelLabel = 'Activity';
protected static ?string $pluralModelLabel = 'Activity';
protected static ?int $navigationSort = 8; protected static ?int $navigationSort = 8;
protected static ?string $navigationIcon = 'tabler-stack'; protected static ?string $navigationIcon = 'tabler-stack';
@ -56,14 +52,16 @@ class ActivityResource extends Resource
->defaultPaginationPageOption(25) ->defaultPaginationPageOption(25)
->columns([ ->columns([
TextColumn::make('event') TextColumn::make('event')
->label(trans('server/activity.event'))
->html() ->html()
->description(fn ($state) => $state) ->description(fn ($state) => $state)
->icon(fn (ActivityLog $activityLog) => $activityLog->getIcon()) ->icon(fn (ActivityLog $activityLog) => $activityLog->getIcon())
->formatStateUsing(fn (ActivityLog $activityLog) => $activityLog->getLabel()), ->formatStateUsing(fn (ActivityLog $activityLog) => $activityLog->getLabel()),
TextColumn::make('user') TextColumn::make('user')
->label(trans('server/activity.user'))
->state(function (ActivityLog $activityLog) use ($server) { ->state(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) { if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user'; return $activityLog->actor_id === null ? trans('server/activity.system') : trans('server/activity.deleted_user');
} }
$user = $activityLog->actor->username; $user = $activityLog->actor->username;
@ -79,6 +77,7 @@ class ActivityResource extends Resource
->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor) ? EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin') : '') ->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor) ? EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin') : '')
->grow(false), ->grow(false),
DateTimeColumn::make('timestamp') DateTimeColumn::make('timestamp')
->label(trans('server/activity.timestamp'))
->since() ->since()
->sortable() ->sortable()
->grow(false), ->grow(false),
@ -89,11 +88,13 @@ class ActivityResource extends Resource
//->visible(fn (ActivityLog $activityLog) => $activityLog->hasAdditionalMetadata()) //->visible(fn (ActivityLog $activityLog) => $activityLog->hasAdditionalMetadata())
->form([ ->form([
Placeholder::make('event') Placeholder::make('event')
->label(trans('server/activity.event'))
->content(fn (ActivityLog $activityLog) => new HtmlString($activityLog->getLabel())), ->content(fn (ActivityLog $activityLog) => new HtmlString($activityLog->getLabel())),
TextInput::make('user') TextInput::make('user')
->label(trans('server/activity.user'))
->formatStateUsing(function (ActivityLog $activityLog) use ($server) { ->formatStateUsing(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) { if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user'; return $activityLog->actor_id === null ? trans('server/activity.system') : trans('server/activity.deleted_user');
} }
$user = $activityLog->actor->username; $user = $activityLog->actor->username;
@ -116,9 +117,10 @@ class ActivityResource extends Resource
->visible(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor)) ->visible(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor))
->url(fn (ActivityLog $activityLog) => EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin')) ->url(fn (ActivityLog $activityLog) => EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin'))
), ),
DateTimePicker::make('timestamp'), DateTimePicker::make('timestamp')
->label(trans('server/activity.timestamp')),
KeyValue::make('properties') KeyValue::make('properties')
->label('Metadata') ->label(trans('server/activity.metadata'))
->formatStateUsing(fn ($state) => Arr::dot($state)), ->formatStateUsing(fn ($state) => Arr::dot($state)),
]), ]),
]) ])
@ -168,4 +170,9 @@ class ActivityResource extends Resource
'index' => Pages\ListActivities::route('/'), 'index' => Pages\ListActivities::route('/'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/activity.title');
}
} }

View File

@ -18,4 +18,9 @@ class ListActivities extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string
{
return trans('server/activity.title');
}
} }

View File

@ -30,10 +30,6 @@ class AllocationResource extends Resource
protected static ?string $model = Allocation::class; protected static ?string $model = Allocation::class;
protected static ?string $modelLabel = 'Network';
protected static ?string $pluralModelLabel = 'Network';
protected static ?int $navigationSort = 7; protected static ?int $navigationSort = 7;
protected static ?string $navigationIcon = 'tabler-network'; protected static ?string $navigationIcon = 'tabler-network';
@ -46,16 +42,17 @@ class AllocationResource extends Resource
return $table return $table
->columns([ ->columns([
TextColumn::make('ip') TextColumn::make('ip')
->label('Address') ->label(trans('server/network.address'))
->formatStateUsing(fn (Allocation $allocation) => $allocation->alias), ->formatStateUsing(fn (Allocation $allocation) => $allocation->alias),
TextColumn::make('alias') TextColumn::make('alias')
->hidden(), ->hidden(),
TextColumn::make('port'), TextColumn::make('port')
->label(trans('server/network.port')),
TextInputColumn::make('notes') TextInputColumn::make('notes')
->label(trans('server/network.notes'))
->visibleFrom('sm') ->visibleFrom('sm')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->label('Notes') ->placeholder(trans('server/network.no_notes')),
->placeholder('No Notes'),
IconColumn::make('primary') IconColumn::make('primary')
->icon(fn ($state) => match ($state) { ->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled', true => 'tabler-star-filled',
@ -65,15 +62,15 @@ class AllocationResource extends Resource
true => 'warning', true => 'warning',
default => 'gray', default => 'gray',
}) })
->tooltip(fn (Allocation $allocation) => ($allocation->id === $server->allocation_id ? 'Already' : 'Make') . ' Primary') ->tooltip(fn (Allocation $allocation) => $allocation->id === $server->allocation_id ? trans('server/network.primary') : trans('server/network.make_primary'))
->action(fn (Allocation $allocation) => auth()->user()->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server) && $server->update(['allocation_id' => $allocation->id])) ->action(fn (Allocation $allocation) => auth()->user()->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server) && $server->update(['allocation_id' => $allocation->id]))
->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id) ->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->label('Primary'), ->label(trans('server/network.primary')),
]) ])
->actions([ ->actions([
DetachAction::make() DetachAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->label('Delete') ->label(trans('server/network.delete'))
->icon('tabler-trash') ->icon('tabler-trash')
->action(function (Allocation $allocation) { ->action(function (Allocation $allocation) {
Allocation::query()->where('id', $allocation->id)->update([ Allocation::query()->where('id', $allocation->id)->update([
@ -117,4 +114,9 @@ class AllocationResource extends Resource
'index' => Pages\ListAllocations::route('/'), 'index' => Pages\ListAllocations::route('/'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/network.title');
}
} }

View File

@ -33,7 +33,7 @@ class ListAllocations extends ListRecords
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'tabler-network-off' : 'tabler-network') ->icon(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'tabler-network-off' : 'tabler-network')
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, $server))
->tooltip(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'Allocation limit reached' : 'Add Allocation') ->tooltip(fn () => $server->allocations()->count() >= $server->allocation_limit ? trans('server/network.limit') : trans('server/network.add'))
->hidden(fn () => !config('panel.client_features.allocations.enabled')) ->hidden(fn () => !config('panel.client_features.allocations.enabled'))
->disabled(fn () => $server->allocations()->count() >= $server->allocation_limit) ->disabled(fn () => $server->allocations()->count() >= $server->allocation_limit)
->color(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'danger' : 'primary') ->color(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'danger' : 'primary')
@ -56,4 +56,14 @@ class ListAllocations extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string
{
return trans('server/network.title');
}
public static function getNavigationLabel(): string
{
return trans('server/network.title');
}
} }

View File

@ -79,14 +79,15 @@ class BackupResource extends Resource
return $form return $form
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label('Name') ->label(trans('server/backup.actions.create.name'))
->columnSpanFull(), ->columnSpanFull(),
TextArea::make('ignored') TextArea::make('ignored')
->columnSpanFull() ->label(trans('server/backup.actions.create.ignored'))
->label('Ignored Files & Directories'), ->columnSpanFull(),
Toggle::make('is_locked') Toggle::make('is_locked')
->label('Lock?') ->label(trans('server/backup.actions.create.locked'))
->helperText('Prevents this backup from being deleted until explicitly unlocked.'), ->helperText(trans('server/backup.actions.create.lock_helper'))
->columnSpanFull(),
]); ]);
} }
@ -98,60 +99,94 @@ class BackupResource extends Resource
return $table return $table
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
->label(trans('server/backup.actions.create.name'))
->searchable(), ->searchable(),
BytesColumn::make('bytes') BytesColumn::make('bytes')
->label('Size'), ->label(trans('server/backup.size')),
DateTimeColumn::make('created_at') DateTimeColumn::make('created_at')
->label('Created') ->label(trans('server/backup.created_at'))
->since() ->since()
->sortable(), ->sortable(),
TextColumn::make('status') TextColumn::make('status')
->label('Status') ->label(trans('server/backup.status'))
->badge(), ->badge(),
IconColumn::make('is_locked') IconColumn::make('is_locked')
->label(trans('server/backup.is_locked'))
->visibleFrom('md') ->visibleFrom('md')
->label('Lock Status')
->trueIcon('tabler-lock') ->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'), ->falseIcon('tabler-lock-open'),
]) ])
->actions([ ->actions([
ActionGroup::make([ ActionGroup::make([
Action::make('rename')
->icon('tabler-pencil')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label('Rename')
->form([
TextInput::make('name')
->label('Backup Name')
->required()
->maxLength(255)
->default(fn (Backup $backup) => $backup->name),
])
->action(function (Backup $backup, $data) {
$oldName = $backup->name;
$newName = $data['name'];
$backup->update(['name' => $newName]);
if ($oldName !== $newName) {
Activity::event('server:backup.rename')
->subject($backup)
->property(['old_name' => $oldName, 'new_name' => $newName])
->log();
}
Notification::make()
->title('Backup Renamed')
->body('The backup has been successfully renamed.')
->success()
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('lock') Action::make('lock')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open') ->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock') ->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup)) ->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful), ->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download') Action::make('download')
->label(trans('server/backup.actions.download'))
->color('primary') ->color('primary')
->icon('tabler-download') ->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true) ->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful), ->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore') Action::make('restore')
->label(trans('server/backup.actions.restore.title'))
->color('success') ->color('success')
->icon('tabler-folder-up') ->icon('tabler-folder-up')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_RESTORE, $server))
->form([ ->form([
Placeholder::make('') Placeholder::make('')
->helperText('Your server will be stopped. You will not be able to control the power state, access the file manager, or create additional backups until this process is completed.'), ->helperText(trans('server/backup.actions.restore.helper')),
Checkbox::make('truncate') Checkbox::make('truncate')
->label('Delete all files before restoring backup?'), ->label(trans('server/backup.actions.restore.delete_all')),
]) ])
->action(function (Backup $backup, $data, DaemonBackupRepository $daemonRepository, DownloadLinkService $downloadLinkService) use ($server) { ->action(function (Backup $backup, $data, DaemonBackupRepository $daemonRepository, DownloadLinkService $downloadLinkService) use ($server) {
if (!is_null($server->status)) { if (!is_null($server->status)) {
return Notification::make() return Notification::make()
->title(trans('server/backup.actions.restore.notification_fail'))
->body(trans('server/backup.actions.restore.notification_fail_body_1'))
->danger() ->danger()
->title('Backup Restore Failed')
->body('This server is not currently in a state that allows for a backup to be restored.')
->send(); ->send();
} }
if (!$backup->is_successful && is_null($backup->completed_at)) { if (!$backup->is_successful && is_null($backup->completed_at)) {
return Notification::make() return Notification::make()
->title(trans('server/backup.actions.restore.notification_fail'))
->body(trans('server/backup.actions.restore.notification_fail_body_2'))
->danger() ->danger()
->title('Backup Restore Failed')
->body('This backup cannot be restored at this time: not completed or failed.')
->send(); ->send();
} }
@ -174,21 +209,26 @@ class BackupResource extends Resource
}); });
return Notification::make() return Notification::make()
->title('Restoring Backup') ->title(trans('server/backup.actions.restore.notification_started'))
->send(); ->send();
}) })
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful), ->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete') DeleteAction::make('delete')
->disabled(fn (Backup $backup) => $backup->is_locked) ->disabled(fn (Backup $backup) => $backup->is_locked)
->modalDescription(fn (Backup $backup) => 'Do you wish to delete ' . $backup->name . '?') ->modalDescription(fn (Backup $backup) => trans('server/backup.actions.delete.description', ['backup' => $backup->name]))
->modalSubmitActionLabel('Delete Backup') ->modalSubmitActionLabel(trans('server/backup.actions.delete.title'))
->action(function (Backup $backup, DeleteBackupService $deleteBackupService) { ->action(function (Backup $backup, DeleteBackupService $deleteBackupService) {
try { try {
$deleteBackupService->handle($backup); $deleteBackupService->handle($backup);
Notification::make()
->title(trans('server/backup.actions.delete.notification_success'))
->success()
->send();
} catch (ConnectionException) { } catch (ConnectionException) {
Notification::make() Notification::make()
->title('Could not delete backup') ->title(trans('server/backup.actions.delete.notification_fail'))
->body('Connection to node failed') ->body(trans('server/backup.actions.delete.notification_fail_body'))
->danger() ->danger()
->send(); ->send();
@ -227,4 +267,9 @@ class BackupResource extends Resource
'index' => Pages\ListBackups::route('/'), 'index' => Pages\ListBackups::route('/'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/backup.title');
}
} }

View File

@ -36,7 +36,7 @@ class ListBackups extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_CREATE, $server))
->icon('tabler-file-zip')->iconButton()->iconSize(IconSize::Large) ->icon('tabler-file-zip')->iconButton()->iconSize(IconSize::Large)
->disabled(fn () => $server->backups()->count() >= $server->backup_limit) ->disabled(fn () => $server->backups()->count() >= $server->backup_limit)
->tooltip(fn () => $server->backups()->count() >= $server->backup_limit ? 'Backup Limit Reached' : 'Create Backup') // Disabled Buttons have no tooltips in v3 :/ ->tooltip(fn () => $server->backups()->count() >= $server->backup_limit ? trans('server/backup.actions.create.limit') : trans('server/backup.actions.create.title'))
->color(fn () => $server->backups()->count() >= $server->backup_limit ? 'danger' : 'primary') ->color(fn () => $server->backups()->count() >= $server->backup_limit ? 'danger' : 'primary')
->createAnother(false) ->createAnother(false)
->action(function (InitiateBackupService $initiateBackupService, $data) use ($server) { ->action(function (InitiateBackupService $initiateBackupService, $data) use ($server) {
@ -55,15 +55,15 @@ class ListBackups extends ListRecords
->log(); ->log();
return Notification::make() return Notification::make()
->title('Backup Created') ->title(trans('server/backup.actions.create.notification_success'))
->body($backup->name . ' created.') ->body(trans('server/backup.actions.create.created', ['name' => $backup->name]))
->success() ->success()
->send(); ->send();
} catch (HttpException $e) { } catch (HttpException $e) {
return Notification::make() return Notification::make()
->danger() ->title(trans('server/backup.actions.create.notification_fail'))
->title('Backup Failed')
->body($e->getMessage() . ' Try again' . ($e->getHeaders()['Retry-After'] ? ' in ' . $e->getHeaders()['Retry-After'] . ' seconds.' : '')) ->body($e->getMessage() . ' Try again' . ($e->getHeaders()['Retry-After'] ? ' in ' . $e->getHeaders()['Retry-After'] . ' seconds.' : ''))
->danger()
->send(); ->send();
} }
}), }),
@ -74,4 +74,9 @@ class ListBackups extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string
{
return trans('server/backup.title');
}
} }

View File

@ -66,13 +66,17 @@ class DatabaseResource extends Resource
return $form return $form
->schema([ ->schema([
TextInput::make('host') TextInput::make('host')
->label(trans('server/database.host'))
->formatStateUsing(fn (Database $database) => $database->address()) ->formatStateUsing(fn (Database $database) => $database->address())
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null), ->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('database') TextInput::make('database')
->label(trans('server/database.database'))
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null), ->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('username') TextInput::make('username')
->label(trans('server/database.username'))
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null), ->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('password') TextInput::make('password')
->label(trans('server/database.password'))
->password()->revealable() ->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server)) ->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction( ->hintAction(
@ -82,11 +86,12 @@ class DatabaseResource extends Resource
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null) ->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn (Database $database) => $database->password), ->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote') TextInput::make('remote')
->label('Connections From'), ->label(trans('server/database.remote')),
TextInput::make('max_connections') TextInput::make('max_connections')
->label(trans('server/database.max_connections'))
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'), ->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
TextInput::make('jdbc') TextInput::make('jdbc')
->label('JDBC Connection String') ->label(trans('server/database.jdbc'))
->password()->revealable() ->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server)) ->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null) ->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
@ -100,12 +105,17 @@ class DatabaseResource extends Resource
return $table return $table
->columns([ ->columns([
TextColumn::make('host') TextColumn::make('host')
->label(trans('server/database.host'))
->state(fn (Database $database) => $database->address()) ->state(fn (Database $database) => $database->address())
->badge(), ->badge(),
TextColumn::make('database'), TextColumn::make('database')
TextColumn::make('username'), ->label(trans('server/database.database')),
TextColumn::make('remote'), TextColumn::make('username')
->label(trans('server/database.username')),
TextColumn::make('remote')
->label(trans('server/database.remote')),
DateTimeColumn::make('created_at') DateTimeColumn::make('created_at')
->label(trans('server/database.created_at'))
->sortable(), ->sortable(),
]) ])
->actions([ ->actions([
@ -148,4 +158,9 @@ class DatabaseResource extends Resource
'index' => Pages\ListDatabases::route('/'), 'index' => Pages\ListDatabases::route('/'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/database.title');
}
} }

View File

@ -35,7 +35,7 @@ class ListDatabases extends ListRecords
CreateAction::make('new') CreateAction::make('new')
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon(fn () => $server->databases()->count() >= $server->database_limit ? 'tabler-database-x' : 'tabler-database-plus') ->icon(fn () => $server->databases()->count() >= $server->database_limit ? 'tabler-database-x' : 'tabler-database-plus')
->tooltip(fn () => $server->databases()->count() >= $server->database_limit ? 'Database limit reached' : 'Create Database') ->tooltip(fn () => $server->databases()->count() >= $server->database_limit ? trans('server/database.limit') : trans('server/database.create_database'))
->disabled(fn () => $server->databases()->count() >= $server->database_limit) ->disabled(fn () => $server->databases()->count() >= $server->database_limit)
->color(fn () => $server->databases()->count() >= $server->database_limit ? 'danger' : 'primary') ->color(fn () => $server->databases()->count() >= $server->database_limit ? 'danger' : 'primary')
->createAnother(false) ->createAnother(false)
@ -44,20 +44,20 @@ class ListDatabases extends ListRecords
->columns(2) ->columns(2)
->schema([ ->schema([
Select::make('database_host_id') Select::make('database_host_id')
->label('Database Host') ->label(trans('server/database.database_host'))
->columnSpan(2) ->columnSpan(2)
->required() ->required()
->placeholder('Select Database Host') ->placeholder(trans('server/database.database_host_select'))
->options(fn () => $server->node->databaseHosts->mapWithKeys(fn (DatabaseHost $databaseHost) => [$databaseHost->id => $databaseHost->name])), ->options(fn () => $server->node->databaseHosts->mapWithKeys(fn (DatabaseHost $databaseHost) => [$databaseHost->id => $databaseHost->name])),
TextInput::make('database') TextInput::make('database')
->label(trans('server/database.name'))
->columnSpan(1) ->columnSpan(1)
->label('Database Name')
->prefix('s'. $server->id . '_') ->prefix('s'. $server->id . '_')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('Leaving this blank will auto generate a random name'), ->hintIconTooltip(trans('server/database.name_hint')),
TextInput::make('remote') TextInput::make('remote')
->label(trans('server/database.connections_from'))
->columnSpan(1) ->columnSpan(1)
->label('Connections From')
->default('%'), ->default('%'),
]), ]),
]) ])
@ -76,4 +76,9 @@ class ListDatabases extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string
{
return trans('server/database.title');
}
} }

View File

@ -55,4 +55,9 @@ class FileResource extends Resource
'index' => Pages\ListFiles::route('/{path?}'), 'index' => Pages\ListFiles::route('/{path?}'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/file.title');
}
} }

View File

@ -69,10 +69,10 @@ class EditFiles extends Page
return $form return $form
->schema([ ->schema([
Section::make('Editing: ' . $this->path) Section::make(trans('server/file.actions.edit.title', ['file' => $this->path]))
->footerActions([ ->footerActions([
Action::make('save_and_close') Action::make('save_and_close')
->label('Save & Close') ->label(trans('server/file.actions.edit.save_close'))
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy') ->icon('tabler-device-floppy')
->keyBindings('mod+shift+s') ->keyBindings('mod+shift+s')
@ -85,14 +85,14 @@ class EditFiles extends Page
Notification::make() Notification::make()
->success() ->success()
->title('File saved') ->title(trans('server/file.actions.edit.notification'))
->body(fn () => $this->path) ->body(fn () => $this->path)
->send(); ->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)])); $this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
}), }),
Action::make('save') Action::make('save')
->label('Save') ->label(trans('server/file.actions.edit.save'))
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy') ->icon('tabler-device-floppy')
->keyBindings('mod+s') ->keyBindings('mod+s')
@ -105,12 +105,12 @@ class EditFiles extends Page
Notification::make() Notification::make()
->success() ->success()
->title('File saved') ->title(trans('server/file.actions.edit.notification'))
->body(fn () => $this->path) ->body(fn () => $this->path)
->send(); ->send();
}), }),
Action::make('cancel') Action::make('cancel')
->label('Cancel') ->label(trans('server/file.actions.edit.cancel'))
->color('danger') ->color('danger')
->icon('tabler-x') ->icon('tabler-x')
->url(fn () => ListFiles::getUrl(['path' => dirname($this->path)])), ->url(fn () => ListFiles::getUrl(['path' => dirname($this->path)])),
@ -118,7 +118,7 @@ class EditFiles extends Page
->footerActionsAlignment(Alignment::End) ->footerActionsAlignment(Alignment::End)
->schema([ ->schema([
Select::make('lang') Select::make('lang')
->label('Syntax Highlighting') ->label(trans('server/file.actions.new_file.syntax'))
->searchable() ->searchable()
->native(false) ->native(false)
->live() ->live()
@ -133,7 +133,7 @@ class EditFiles extends Page
try { try {
return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size')); return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
} catch (FileSizeTooLargeException) { } catch (FileSizeTooLargeException) {
AlertBanner::make() AlertBanner::make('file_too_large')
->title('<code>' . basename($this->path) . '</code> is too large!') ->title('<code>' . basename($this->path) . '</code> is too large!')
->body('Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size'))) ->body('Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->danger() ->danger()
@ -142,7 +142,7 @@ class EditFiles extends Page
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)])); $this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotFoundException) { } catch (FileNotFoundException) {
AlertBanner::make() AlertBanner::make('file_not_found')
->title('<code>' . basename($this->path) . '</code> not found!') ->title('<code>' . basename($this->path) . '</code> not found!')
->danger() ->danger()
->closable() ->closable()
@ -150,7 +150,7 @@ class EditFiles extends Page
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)])); $this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotEditableException) { } catch (FileNotEditableException) {
AlertBanner::make() AlertBanner::make('file_is_directory')
->title('<code>' . basename($this->path) . '</code> is a directory') ->title('<code>' . basename($this->path) . '</code> is a directory')
->danger() ->danger()
->closable() ->closable()
@ -184,15 +184,6 @@ class EditFiles extends Page
->info() ->info()
->closable() ->closable()
->send(); ->send();
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
} }
} }

View File

@ -90,14 +90,17 @@ class ListFiles extends ListRecords
->defaultSort('name') ->defaultSort('name')
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
->label(trans('server/file.name'))
->searchable() ->searchable()
->sortable() ->sortable()
->icon(fn (File $file) => $file->getIcon()), ->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size') BytesColumn::make('size')
->label(trans('server/file.size'))
->visibleFrom('md') ->visibleFrom('md')
->state(fn (File $file) => $file->is_directory ? null : $file->size) ->state(fn (File $file) => $file->is_directory ? null : $file->size)
->sortable(), ->sortable(),
DateTimeColumn::make('modified_at') DateTimeColumn::make('modified_at')
->label(trans('server/file.modified_at'))
->visibleFrom('md') ->visibleFrom('md')
->since() ->since()
->sortable(), ->sortable(),
@ -116,7 +119,7 @@ class ListFiles extends ListRecords
->actions([ ->actions([
Action::make('view') Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->label('Open') ->label(trans('server/file.actions.open'))
->icon('tabler-eye') ->icon('tabler-eye')
->visible(fn (File $file) => $file->is_directory) ->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])), ->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])),
@ -128,11 +131,11 @@ class ListFiles extends ListRecords
ActionGroup::make([ ActionGroup::make([
Action::make('rename') Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Rename') ->label(trans('server/file.actions.rename.title'))
->icon('tabler-forms') ->icon('tabler-forms')
->form([ ->form([
TextInput::make('name') TextInput::make('name')
->label('File name') ->label(trans('server/file.actions.rename.name'))
->default(fn (File $file) => $file->name) ->default(fn (File $file) => $file->name)
->required(), ->required(),
]) ])
@ -149,14 +152,14 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('File Renamed') ->title(trans('server/file.actions.rename.notification'))
->body(fn () => $file->name . ' -> ' . $data['name']) ->body(fn () => $file->name . ' -> ' . $data['name'])
->success() ->success()
->send(); ->send();
}), }),
Action::make('copy') Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('Copy') ->label(trans('server/file.actions.copy.title'))
->icon('tabler-copy') ->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file) ->visible(fn (File $file) => $file->is_file)
->action(function (File $file) { ->action(function (File $file) {
@ -167,7 +170,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('File copied') ->title(trans('server/file.actions.copy.notification'))
->success() ->success()
->send(); ->send();
@ -175,18 +178,18 @@ class ListFiles extends ListRecords
}), }),
Action::make('download') Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->label('Download') ->label(trans('server/file.actions.download'))
->icon('tabler-download') ->icon('tabler-download')
->visible(fn (File $file) => $file->is_file) ->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true), ->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true),
Action::make('move') Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Move') ->label(trans('server/file.actions.move.title'))
->icon('tabler-replace') ->icon('tabler-replace')
->form([ ->form([
TextInput::make('location') TextInput::make('location')
->label('New location') ->label(trans('server/file.actions.move.new_location'))
->hint('Enter the location of this file or folder, relative to the current directory.') ->hint(trans('server/file.actions.move.new_location_hint'))
->required() ->required()
->live(), ->live(),
Placeholder::make('new_location') Placeholder::make('new_location')
@ -209,22 +212,24 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('File Moved') ->title(trans('server/file.actions.move.notification'))
->body($oldLocation . ' -> ' . $newLocation) ->body($oldLocation . ' -> ' . $newLocation)
->success() ->success()
->send(); ->send();
}), }),
Action::make('permissions') Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Permissions') ->label(trans('server/file.actions.permissions.title'))
->icon('tabler-license') ->icon('tabler-license')
->form([ ->form([
CheckboxList::make('owner') CheckboxList::make('owner')
->label(trans('server/file.actions.permissions.owner'))
->bulkToggleable() ->bulkToggleable()
->columns(3)
->options([ ->options([
'read' => 'Read', 'read' => trans('server/file.actions.permissions.read'),
'write' => 'Write', 'write' => trans('server/file.actions.permissions.write'),
'execute' => 'Execute', 'execute' => trans('server/file.actions.permissions.execute'),
]) ])
->formatStateUsing(function ($state, File $file) { ->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 0, 1); $mode = (int) substr((string) $file->mode_bits, 0, 1);
@ -232,11 +237,13 @@ class ListFiles extends ListRecords
return $this->getPermissionsFromModeBit($mode); return $this->getPermissionsFromModeBit($mode);
}), }),
CheckboxList::make('group') CheckboxList::make('group')
->label(trans('server/file.actions.permissions.group'))
->bulkToggleable() ->bulkToggleable()
->columns(3)
->options([ ->options([
'read' => 'Read', 'read' => trans('server/file.actions.permissions.read'),
'write' => 'Write', 'write' => trans('server/file.actions.permissions.write'),
'execute' => 'Execute', 'execute' => trans('server/file.actions.permissions.execute'),
]) ])
->formatStateUsing(function ($state, File $file) { ->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 1, 1); $mode = (int) substr((string) $file->mode_bits, 1, 1);
@ -244,11 +251,13 @@ class ListFiles extends ListRecords
return $this->getPermissionsFromModeBit($mode); return $this->getPermissionsFromModeBit($mode);
}), }),
CheckboxList::make('public') CheckboxList::make('public')
->label(trans('server/file.actions.permissions.public'))
->bulkToggleable() ->bulkToggleable()
->columns(3)
->options([ ->options([
'read' => 'Read', 'read' => trans('server/file.actions.permissions.read'),
'write' => 'Write', 'write' => trans('server/file.actions.permissions.write'),
'execute' => 'Execute', 'execute' => trans('server/file.actions.permissions.execute'),
]) ])
->formatStateUsing(function ($state, File $file) { ->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 2, 1); $mode = (int) substr((string) $file->mode_bits, 2, 1);
@ -266,17 +275,17 @@ class ListFiles extends ListRecords
$this->getDaemonFileRepository()->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]); $this->getDaemonFileRepository()->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]);
Notification::make() Notification::make()
->title('Permissions changed to ' . $mode) ->title(trans('server/file.actions.permissions.notification', ['mode' => $mode]))
->success() ->success()
->send(); ->send();
}), }),
Action::make('archive') Action::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label('Archive') ->label(trans('server/file.actions.archive.title'))
->icon('tabler-archive') ->icon('tabler-archive')
->form([ ->form([
TextInput::make('name') TextInput::make('name')
->label('Archive name') ->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'), ->suffix('.tar.gz'),
]) ])
@ -290,7 +299,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('Archive created') ->title(trans('server/file.actions.archive.notification'))
->body($archive['name']) ->body($archive['name'])
->success() ->success()
->send(); ->send();
@ -299,7 +308,7 @@ class ListFiles extends ListRecords
}), }),
Action::make('unarchive') Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label('Unarchive') ->label(trans('server/file.actions.unarchive.title'))
->icon('tabler-archive') ->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive()) ->visible(fn (File $file) => $file->isArchive())
->action(function (File $file) { ->action(function (File $file) {
@ -311,7 +320,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('Unarchive completed') ->title(trans('server/file.actions.unarchive.notification'))
->success() ->success()
->send(); ->send();
@ -339,8 +348,8 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->form([ ->form([
TextInput::make('location') TextInput::make('location')
->label('Directory') ->label(trans('server/file.actions.move.directory'))
->hint('Enter the new directory, relative to the current directory.') ->hint(trans('server/file.actions.move.directory_hint'))
->required() ->required()
->live(), ->live(),
Placeholder::make('new_location') Placeholder::make('new_location')
@ -358,7 +367,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location))) ->title(trans('server/file.actions.move.bulk_notification', ['count' => count($files), 'directory' => resolve_path(join_paths($this->path, $location))]))
->success() ->success()
->send(); ->send();
}), }),
@ -366,7 +375,7 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->form([ ->form([
TextInput::make('name') TextInput::make('name')
->label('Archive name') ->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'), ->suffix('.tar.gz'),
]) ])
@ -382,7 +391,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title('Archive created') ->title(trans('server/file.actions.archive.notification'))
->body($archive['name']) ->body($archive['name'])
->success() ->success()
->send(); ->send();
@ -401,7 +410,7 @@ class ListFiles extends ListRecords
->log(); ->log();
Notification::make() Notification::make()
->title(count($files) . ' Files deleted.') ->title(trans('server/file.actions.delete.bulk_notification', ['count' => count($files)]))
->success() ->success()
->send(); ->send();
}), }),
@ -417,10 +426,10 @@ class ListFiles extends ListRecords
return [ return [
HeaderAction::make('new_file') HeaderAction::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->tooltip('New File') ->tooltip(trans('server/file.actions.new_file.title'))
->hiddenLabel()->icon('tabler-file-plus')->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->icon('tabler-file-plus')->iconButton()->iconSize(IconSize::Large)
->color('primary') ->color('primary')
->modalSubmitActionLabel('Create') ->modalSubmitActionLabel(trans('server/file.actions.new_file.create'))
->action(function ($data) { ->action(function ($data) {
$path = join_paths($this->path, $data['name']); $path = join_paths($this->path, $data['name']);
try { try {
@ -430,7 +439,7 @@ class ListFiles extends ListRecords
->property('file', join_paths($path, $data['name'])) ->property('file', join_paths($path, $data['name']))
->log(); ->log();
} catch (FileExistsException) { } catch (FileExistsException) {
AlertBanner::make() AlertBanner::make('file_already_exists')
->title('<code>' . $path . '</code> already exists!') ->title('<code>' . $path . '</code> already exists!')
->danger() ->danger()
->closable() ->closable()
@ -441,10 +450,10 @@ class ListFiles extends ListRecords
}) })
->form([ ->form([
TextInput::make('name') TextInput::make('name')
->label('File Name') ->label(trans('server/file.actions.new_file.file_name'))
->required(), ->required(),
Select::make('lang') Select::make('lang')
->label('Syntax Highlighting') ->label(trans('server/file.actions.new_file.syntax'))
->searchable() ->searchable()
->native(false) ->native(false)
->live() ->live()
@ -460,7 +469,7 @@ class ListFiles extends ListRecords
HeaderAction::make('new_folder') HeaderAction::make('new_folder')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->hiddenLabel()->icon('tabler-folder-plus')->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->icon('tabler-folder-plus')->iconButton()->iconSize(IconSize::Large)
->tooltip('New Folder') ->tooltip(trans('server/file.actions.new_folder.title'))
->color('primary') ->color('primary')
->action(function ($data) { ->action(function ($data) {
try { try {
@ -471,7 +480,7 @@ class ListFiles extends ListRecords
->log(); ->log();
} catch (FileExistsException) { } catch (FileExistsException) {
$path = join_paths($this->path, $data['name']); $path = join_paths($this->path, $data['name']);
AlertBanner::make() AlertBanner::make('folder_already_exists')
->title('<code>' . $path . '</code> already exists!') ->title('<code>' . $path . '</code> already exists!')
->danger() ->danger()
->closable() ->closable()
@ -482,13 +491,13 @@ class ListFiles extends ListRecords
}) })
->form([ ->form([
TextInput::make('name') TextInput::make('name')
->label('Folder Name') ->label(trans('server/file.actions.new_folder.folder_name'))
->required(), ->required(),
]), ]),
HeaderAction::make('upload') HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->hiddenLabel()->icon('tabler-upload')->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->icon('tabler-upload')->iconButton()->iconSize(IconSize::Large)
->tooltip('Upload') ->tooltip(trans('server/file.actions.upload.title'))
->color('success') ->color('success')
->action(function ($data) { ->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) { if (count($data['files']) > 0 && !isset($data['url'])) {
@ -516,7 +525,7 @@ class ListFiles extends ListRecords
Tabs::make() Tabs::make()
->contained(false) ->contained(false)
->schema([ ->schema([
Tab::make('Upload Files') Tab::make(trans('server/file.actions.upload.from_files'))
->live() ->live()
->schema([ ->schema([
FileUpload::make('files') FileUpload::make('files')
@ -526,12 +535,12 @@ class ListFiles extends ListRecords
->maxSize((int) round($server->node->upload_size * (config('panel.use_binary_prefix') ? 1.048576 * 1024 : 1000))) ->maxSize((int) round($server->node->upload_size * (config('panel.use_binary_prefix') ? 1.048576 * 1024 : 1000)))
->multiple(), ->multiple(),
]), ]),
Tab::make('Upload From URL') Tab::make(trans('server/file.actions.upload.url'))
->live() ->live()
->disabled(fn (Get $get) => count($get('files')) > 0) ->disabled(fn (Get $get) => count($get('files')) > 0)
->schema([ ->schema([
TextInput::make('url') TextInput::make('url')
->label('URL') ->label(trans('server/file.actions.upload.url'))
->url(), ->url(),
]), ]),
]), ]),
@ -539,14 +548,15 @@ class ListFiles extends ListRecords
HeaderAction::make('search') HeaderAction::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->tooltip('Global Search') ->tooltip(trans('server/file.actions.global_search.title'))
->color('primary') ->color('primary')
->icon('tabler-world-search') ->icon('tabler-world-search')
->modalHeading('Global Search') ->modalHeading(trans('server/file.actions.global_search.title'))
->modalSubmitActionLabel('Search') ->modalSubmitActionLabel(trans('server/file.actions.global_search.search'))
->form([ ->form([
TextInput::make('searchTerm') TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt') ->label(trans('server/file.actions.global_search.search_term'))
->placeholder(trans('server/file.actions.global_search.search_term_placeholder'))
->required() ->required()
->regex('/^[^*]*\*?[^*]*$/') ->regex('/^[^*]*\*?[^*]*$/')
->minValue(3), ->minValue(3),
@ -594,4 +604,9 @@ class ListFiles extends ListRecords
->where('path', '.*'), ->where('path', '.*'),
); );
} }
public function getTitle(): string
{
return trans('server/file.title');
}
} }

View File

@ -13,6 +13,7 @@ use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
@ -23,8 +24,6 @@ class SearchFiles extends ListRecords
protected static string $resource = FileResource::class; protected static string $resource = FileResource::class;
protected static ?string $title = 'Global Search';
#[Locked] #[Locked]
public string $searchTerm; public string $searchTerm;
@ -37,7 +36,7 @@ class SearchFiles extends ListRecords
return [ return [
$resource::getUrl() => $resource::getBreadcrumb(), $resource::getUrl() => $resource::getBreadcrumb(),
self::getUrl(['searchTerm' => $this->searchTerm]) => 'Search "' . $this->searchTerm . '"', self::getUrl(['searchTerm' => $this->searchTerm]) => trans('server/file.actions.global_search.search') . ' "' . $this->searchTerm . '"',
]; ];
} }
@ -51,10 +50,18 @@ class SearchFiles extends ListRecords
->query(fn () => File::get($server, $this->path, $this->searchTerm)->orderByDesc('is_directory')->orderBy('name')) ->query(fn () => File::get($server, $this->path, $this->searchTerm)->orderByDesc('is_directory')->orderBy('name'))
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
->label(trans('server/file.name'))
->searchable() ->searchable()
->sortable()
->icon(fn (File $file) => $file->getIcon()), ->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size'), BytesColumn::make('size')
->label(trans('server/file.size'))
->visibleFrom('md')
->state(fn (File $file) => $file->size)
->sortable(),
DateTimeColumn::make('modified_at') DateTimeColumn::make('modified_at')
->label(trans('server/file.modified_at'))
->visibleFrom('md')
->since() ->since()
->sortable(), ->sortable(),
]) ])
@ -66,4 +73,9 @@ class SearchFiles extends ListRecords
return $file->canEdit() ? EditFiles::getUrl(['path' => join_paths($this->path, $file->name)]) : null; return $file->canEdit() ? EditFiles::getUrl(['path' => join_paths($this->path, $file->name)]) : null;
}); });
} }
public function getTitle(): string|Htmlable
{
return trans('server/file.actions.global_search.title');
}
} }

View File

@ -85,21 +85,20 @@ class ScheduleResource extends Resource
]) ])
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label(trans('server/schedule.name'))
->columnSpanFull() ->columnSpanFull()
->label('Schedule Name')
->placeholder('A human readable identifier for this schedule.')
->autocomplete(false) ->autocomplete(false)
->required(), ->required(),
Toggle::make('only_when_online') Toggle::make('only_when_online')
->label('Only when Server is Online?') ->label(trans('server/schedule.only_online'))
->hintIconTooltip('Only execute this schedule when the server is in a running state.') ->hintIconTooltip(trans('server/schedule.only_online_hint'))
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->inline(false) ->inline(false)
->required() ->required()
->default(1), ->default(1),
Toggle::make('is_active') Toggle::make('is_active')
->label('Enable Schedule?') ->label(trans('server/schedule.enabled'))
->hintIconTooltip('This schedule will be executed automatically if enabled.') ->hintIconTooltip(trans('server/schedule.enabled_hint'))
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->inline(false) ->inline(false)
->hiddenOn('view') ->hiddenOn('view')
@ -107,7 +106,7 @@ class ScheduleResource extends Resource
->default(1), ->default(1),
ToggleButtons::make('Status') ToggleButtons::make('Status')
->formatStateUsing(fn (Schedule $schedule) => !$schedule->is_active ? 'inactive' : ($schedule->is_processing ? 'processing' : 'active')) ->formatStateUsing(fn (Schedule $schedule) => !$schedule->is_active ? 'inactive' : ($schedule->is_processing ? 'processing' : 'active'))
->options(fn (Schedule $schedule) => !$schedule->is_active ? ['inactive' => 'Inactive'] : ($schedule->is_processing ? ['processing' => 'Processing'] : ['active' => 'Active'])) ->options(fn (Schedule $schedule) => !$schedule->is_active ? ['inactive' => trans('server/schedule.inactive')] : ($schedule->is_processing ? ['processing' => trans('server/schedule.processing')] : ['active' => trans('server/schedule.active')]))
->colors([ ->colors([
'inactive' => 'danger', 'inactive' => 'danger',
'processing' => 'warning', 'processing' => 'warning',
@ -115,22 +114,35 @@ class ScheduleResource extends Resource
]) ])
->visibleOn('view'), ->visibleOn('view'),
Section::make('Cron') Section::make('Cron')
->description(fn (Get $get) => new HtmlString('Please keep in mind that the cron inputs below always assume UTC.<br>Next run in your timezone (' . auth()->user()->timezone . '): <b>'. Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(auth()->user()->timezone) . '</b>')) ->label(trans('server/schedule.cron'))
->description(function (Get $get) {
try {
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(auth()->user()->timezone);
} catch (Exception) {
$nextRun = trans('server/schedule.invalid');
}
return new HtmlString(trans('server/schedule.cron_body') . '<br>' . trans('server/schedule.cron_timezone', ['timezone' => auth()->user()->timezone, 'next_run' => $nextRun]));
})
->schema([ ->schema([
Actions::make([ Actions::make([
CronPresetAction::make('hourly') CronPresetAction::make('hourly')
->label(trans('server/schedule.time.hourly'))
->cron('0', '*', '*', '*', '*'), ->cron('0', '*', '*', '*', '*'),
CronPresetAction::make('daily') CronPresetAction::make('daily')
->label(trans('server/schedule.time.daily'))
->cron('0', '0', '*', '*', '*'), ->cron('0', '0', '*', '*', '*'),
CronPresetAction::make('weekly_monday') CronPresetAction::make('weekly_monday')
->label('Weekly (Monday)') ->label(trans('server/schedule.time.weekly_mon'))
->cron('0', '0', '*', '*', '1'), ->cron('0', '0', '*', '*', '1'),
CronPresetAction::make('weekly_sunday') CronPresetAction::make('weekly_sunday')
->label('Weekly (Sunday)') ->label(trans('server/schedule.time.weekly_sun'))
->cron('0', '0', '*', '*', '0'), ->cron('0', '0', '*', '*', '0'),
CronPresetAction::make('monthly') CronPresetAction::make('monthly')
->label(trans('server/schedule.time.monthly'))
->cron('0', '0', '1', '*', '*'), ->cron('0', '0', '1', '*', '*'),
CronPresetAction::make('every_x_minutes') CronPresetAction::make('every_x_minutes')
->label(trans('server/schedule.time.every_min'))
->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/') ->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/')
&& $get('cron_hour') == '*' && $get('cron_hour') == '*'
&& $get('cron_day_of_month') == '*' && $get('cron_day_of_month') == '*'
@ -142,8 +154,8 @@ class ScheduleResource extends Resource
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(60) ->maxValue(60)
->prefix('Every') ->prefix(trans('server/schedule.time.every'))
->suffix('Minutes'), ->suffix(trans('server/schedule.time.minutes')),
]) ])
->action(function (Set $set, $data) { ->action(function (Set $set, $data) {
$set('cron_minute', '*/' . $data['x']); $set('cron_minute', '*/' . $data['x']);
@ -164,8 +176,8 @@ class ScheduleResource extends Resource
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(24) ->maxValue(24)
->prefix('Every') ->prefix(trans('server/schedule.time.every'))
->suffix('Hours'), ->suffix(trans('server/schedule.time.hours')),
]) ])
->action(function (Set $set, $data) { ->action(function (Set $set, $data) {
$set('cron_minute', '0'); $set('cron_minute', '0');
@ -186,8 +198,8 @@ class ScheduleResource extends Resource
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(24) ->maxValue(24)
->prefix('Every') ->prefix(trans('server/schedule.time.every'))
->suffix('Days'), ->suffix(trans('server/schedule.time.days')),
]) ])
->action(function (Set $set, $data) { ->action(function (Set $set, $data) {
$set('cron_minute', '0'); $set('cron_minute', '0');
@ -208,8 +220,8 @@ class ScheduleResource extends Resource
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(24) ->maxValue(24)
->prefix('Every') ->prefix(trans('server/schedule.time.every'))
->suffix('Months'), ->suffix(trans('server/schedule.time.months')),
]) ])
->action(function (Set $set, $data) { ->action(function (Set $set, $data) {
$set('cron_minute', '0'); $set('cron_minute', '0');
@ -227,15 +239,15 @@ class ScheduleResource extends Resource
->form([ ->form([
Select::make('x') Select::make('x')
->label('') ->label('')
->prefix('Every') ->prefix(trans('server/schedule.time.every'))
->options([ ->options([
'1' => 'Monday', '1' => trans('server/schedule.time.monday'),
'2' => 'Tuesday', '2' => trans('server/schedule.time.tuesday'),
'3' => 'Wednesday', '3' => trans('server/schedule.time.wednesday'),
'4' => 'Thursday', '4' => trans('server/schedule.time.thursday'),
'5' => 'Friday', '5' => trans('server/schedule.time.friday'),
'6' => 'Saturday', '6' => trans('server/schedule.time.saturday'),
'0' => 'Sunday', '0' => trans('server/schedule.time.sunday'),
]) ])
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->native(false), ->native(false),
@ -251,47 +263,47 @@ class ScheduleResource extends Resource
->hiddenOn('view'), ->hiddenOn('view'),
Group::make([ Group::make([
TextInput::make('cron_minute') TextInput::make('cron_minute')
->label(trans('server/schedule.time.minute'))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'lg' => 1, 'lg' => 1,
]) ])
->label('Minute')
->default('*/5') ->default('*/5')
->required() ->required()
->live(), ->live(),
TextInput::make('cron_hour') TextInput::make('cron_hour')
->label(trans('server/schedule.time.hour'))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'lg' => 1, 'lg' => 1,
]) ])
->label('Hour')
->default('*') ->default('*')
->required() ->required()
->live(), ->live(),
TextInput::make('cron_day_of_month') TextInput::make('cron_day_of_month')
->label(trans('server/schedule.time.day_of_month'))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'lg' => 1, 'lg' => 1,
]) ])
->label('Day of Month')
->default('*') ->default('*')
->required() ->required()
->live(), ->live(),
TextInput::make('cron_month') TextInput::make('cron_month')
->label(trans('server/schedule.time.month'))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'lg' => 1, 'lg' => 1,
]) ])
->label('Month')
->default('*') ->default('*')
->required() ->required()
->live(), ->live(),
TextInput::make('cron_day_of_week') TextInput::make('cron_day_of_week')
->label(trans('server/schedule.time.day_of_week'))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'lg' => 1, 'lg' => 1,
]) ])
->label('Day of Week')
->default('*') ->default('*')
->required() ->required()
->live(), ->live(),
@ -309,22 +321,26 @@ class ScheduleResource extends Resource
return $table return $table
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
->label(trans('server/schedule.name'))
->searchable(), ->searchable(),
TextColumn::make('cron') TextColumn::make('cron')
->label(trans('server/schedule.cron'))
->state(fn (Schedule $schedule) => $schedule->cron_minute . ' ' . $schedule->cron_hour . ' ' . $schedule->cron_day_of_month . ' ' . $schedule->cron_month . ' ' . $schedule->cron_day_of_week), ->state(fn (Schedule $schedule) => $schedule->cron_minute . ' ' . $schedule->cron_hour . ' ' . $schedule->cron_day_of_month . ' ' . $schedule->cron_month . ' ' . $schedule->cron_day_of_week),
TextColumn::make('status') TextColumn::make('status')
->state(fn (Schedule $schedule) => !$schedule->is_active ? 'Inactive' : ($schedule->is_processing ? 'Processing' : 'Active')), ->label(trans('server/schedule.status'))
->state(fn (Schedule $schedule) => !$schedule->is_active ? trans('server/schedule.inactive') : ($schedule->is_processing ? trans('server/schedule.processing') : trans('server/schedule.active'))),
IconColumn::make('only_when_online') IconColumn::make('only_when_online')
->label(trans('server/schedule.online_only'))
->boolean() ->boolean()
->sortable(), ->sortable(),
DateTimeColumn::make('last_run_at') DateTimeColumn::make('last_run_at')
->label('Last run') ->label(trans('server/schedule.last_run'))
->placeholder('Never') ->placeholder(trans('server/schedule.never'))
->since() ->since()
->sortable(), ->sortable(),
DateTimeColumn::make('next_run_at') DateTimeColumn::make('next_run_at')
->label('Next run') ->label(trans('server/schedule.next_run'))
->placeholder('Never') ->placeholder(trans('server/schedule.never'))
->since() ->since()
->sortable() ->sortable()
->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null), ->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null),
@ -367,11 +383,16 @@ class ScheduleResource extends Resource
return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek); return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek);
} catch (Exception) { } catch (Exception) {
Notification::make() Notification::make()
->title('The cron data provided does not evaluate to a valid expression') ->title(trans('server/schedule.notification_invalid_cron'))
->danger() ->danger()
->send(); ->send();
throw new Halt(); throw new Halt();
} }
} }
public static function getNavigationLabel(): string
{
return trans('server/schedule.title');
}
} }

View File

@ -49,7 +49,7 @@ class EditSchedule extends EditRecord
Actions\DeleteAction::make() Actions\DeleteAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-trash') ->icon('tabler-trash')
->tooltip('Delete Schedule') ->tooltip(trans('server/schedule.delete'))
->after(function ($record) { ->after(function ($record) {
Activity::event('server:schedule.delete') Activity::event('server:schedule.delete')
->property('name', $record->name) ->property('name', $record->name)
@ -58,15 +58,15 @@ class EditSchedule extends EditRecord
ExportScheduleAction::make() ExportScheduleAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-download') ->icon('tabler-download')
->tooltip('Export Schedule'), ->tooltip(trans('server/schedule.export')),
$this->getSaveFormAction()->formId('form') $this->getSaveFormAction()->formId('form')
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-device-floppy') ->icon('tabler-device-floppy')
->tooltip('Save Schedule'), ->tooltip(trans('server/schedule.save')),
$this->getCancelFormAction()->formId('form') $this->getCancelFormAction()->formId('form')
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-cancel') ->icon('tabler-cancel')
->tooltip('Cancel'), ->tooltip(trans('server/schedule.cancel')),
]; ];
} }

View File

@ -26,11 +26,11 @@ class ListSchedules extends ListRecords
CreateAction::make() CreateAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-calendar-plus') ->icon('tabler-calendar-plus')
->tooltip('New Schedule'), ->tooltip(trans('server/schedule.new')),
ImportScheduleAction::make() ImportScheduleAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-download') ->icon('tabler-download')
->tooltip('Import Schedule'), ->tooltip(trans('server/schedule.import')),
]; ];
} }
@ -38,4 +38,9 @@ class ListSchedules extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string
{
return trans('server/schedule.title');
}
} }

View File

@ -29,7 +29,7 @@ class ViewSchedule extends ViewRecord
return [ return [
Action::make('runNow') Action::make('runNow')
->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant())) ->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? 'No tasks' : ($schedule->is_processing ? 'Processing' : 'Run now')) ->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? trans('server/schedule.no_tasks') : ($schedule->is_processing ? trans('server/schedule.processing') : trans('server/schedule.run_now')))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing ? 'warning' : 'primary') ->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing ? 'warning' : 'primary')
->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing) ->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing)
->action(function (ProcessScheduleService $service, Schedule $schedule) { ->action(function (ProcessScheduleService $service, Schedule $schedule) {
@ -45,7 +45,7 @@ class ViewSchedule extends ViewRecord
EditAction::make() EditAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-calendar-code') ->icon('tabler-calendar-code')
->tooltip('Edit Schedule'), ->tooltip(trans('server/schedule.edit')),
]; ];
} }

View File

@ -30,10 +30,10 @@ class TasksRelationManager extends RelationManager
private function getActionOptions(bool $full = true): array private function getActionOptions(bool $full = true): array
{ {
return [ return [
Task::ACTION_POWER => $full ? 'Send power action' : 'Power action', Task::ACTION_POWER => $full ? trans('server/schedule.tasks.actions.power.title') : trans('server/schedule.tasks.actions.power.action'),
Task::ACTION_COMMAND => $full ? 'Send command' : 'Command', Task::ACTION_COMMAND => $full ? trans('server/schedule.tasks.actions.command.title') : trans('server/schedule.tasks.actions.command.command'),
Task::ACTION_BACKUP => $full ? 'Create backup' : 'Files to ignore', Task::ACTION_BACKUP => $full ? trans('server/schedule.tasks.actions.backup.title') : trans('server/schedule.tasks.actions.backup.files_to_ignore'),
Task::ACTION_DELETE_FILES => $full ? 'Delete files' : 'Files to delete', Task::ACTION_DELETE_FILES => $full ? trans('server/schedule.tasks.actions.delete.title') : trans('server/schedule.tasks.actions.delete.files_to_delete'),
]; ];
} }
@ -44,6 +44,7 @@ class TasksRelationManager extends RelationManager
{ {
return [ return [
Select::make('action') Select::make('action')
->label(trans('server/schedule.tasks.actions.title'))
->required() ->required()
->live() ->live()
->disableOptionWhen(fn (string $value) => $value === Task::ACTION_BACKUP && $schedule->server->backup_limit === 0) ->disableOptionWhen(fn (string $value) => $value === Task::ACTION_BACKUP && $schedule->server->backup_limit === 0)
@ -53,27 +54,29 @@ class TasksRelationManager extends RelationManager
->afterStateUpdated(fn ($state, Set $set) => $set('payload', $state === Task::ACTION_POWER ? 'restart' : null)), ->afterStateUpdated(fn ($state, Set $set) => $set('payload', $state === Task::ACTION_POWER ? 'restart' : null)),
Textarea::make('payload') Textarea::make('payload')
->hidden(fn (Get $get) => $get('action') === Task::ACTION_POWER) ->hidden(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label(fn (Get $get) => $this->getActionOptions(false)[$get('action')] ?? 'Payload'), ->label(fn (Get $get) => $this->getActionOptions(false)[$get('action')] ?? trans('server/schedule.tasks.payload')),
Select::make('payload') Select::make('payload')
->visible(fn (Get $get) => $get('action') === Task::ACTION_POWER) ->visible(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label('Power Action') ->label(trans('server/schedule.tasks.actions.power.action'))
->required() ->required()
->options([ ->options([
'start' => 'Start', 'start' => trans('server/schedule.tasks.actions.power.start'),
'restart' => 'Restart', 'restart' => trans('server/schedule.tasks.actions.power.restart'),
'stop' => 'Stop', 'stop' => trans('server/schedule.tasks.actions.power.stop'),
'kill' => 'Kill', 'kill' => trans('server/schedule.tasks.actions.power.kill'),
]) ])
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->default('restart'), ->default('restart'),
TextInput::make('time_offset') TextInput::make('time_offset')
->label(trans('server/schedule.tasks.time_offset'))
->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1) ->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1)
->default(0) ->default(0)
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->maxValue(900) ->maxValue(900)
->suffix('Seconds'), ->suffix(trans('server/schedule.tasks.seconds')),
Toggle::make('continue_on_failure'), Toggle::make('continue_on_failure')
->label(trans('server/schedule.tasks.continue_on_failure')),
]; ];
} }
@ -87,17 +90,21 @@ class TasksRelationManager extends RelationManager
->defaultSort('sequence_id') ->defaultSort('sequence_id')
->columns([ ->columns([
TextColumn::make('action') TextColumn::make('action')
->label(trans('server/schedule.tasks.actions.title'))
->state(fn (Task $task) => $this->getActionOptions()[$task->action] ?? $task->action), ->state(fn (Task $task) => $this->getActionOptions()[$task->action] ?? $task->action),
TextColumn::make('payload') TextColumn::make('payload')
->label(trans('server/schedule.tasks.payload'))
->state(fn (Task $task) => match ($task->payload) { ->state(fn (Task $task) => match ($task->payload) {
'start', 'restart', 'stop', 'kill' => mb_ucfirst($task->payload), 'start', 'restart', 'stop', 'kill' => mb_ucfirst($task->payload),
default => explode(PHP_EOL, $task->payload) default => explode(PHP_EOL, $task->payload)
}) })
->badge(), ->badge(),
TextColumn::make('time_offset') TextColumn::make('time_offset')
->label(trans('server/schedule.tasks.time_offset'))
->hidden(fn () => config('queue.default') === 'sync') ->hidden(fn () => config('queue.default') === 'sync')
->suffix(' Seconds'), ->suffix(' '. trans('server/schedule.tasks.seconds')),
IconColumn::make('continue_on_failure') IconColumn::make('continue_on_failure')
->label(trans('server/schedule.tasks.continue_on_failure'))
->boolean(), ->boolean(),
]) ])
->actions([ ->actions([
@ -133,7 +140,7 @@ class TasksRelationManager extends RelationManager
->headerActions([ ->headerActions([
CreateAction::make() CreateAction::make()
->createAnother(false) ->createAnother(false)
->label(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10) ? 'Task Limit Reached' : 'Create Task') ->label(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10) ? trans('server/schedule.tasks.limit') : trans('server/schedule.tasks.create'))
->disabled(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10)) ->disabled(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10))
->form($this->getTaskForm($schedule)) ->form($this->getTaskForm($schedule))
->action(function ($data) use ($schedule) { ->action(function ($data) use ($schedule) {

View File

@ -91,14 +91,14 @@ class UserResource extends Resource
foreach ($data['permissions'] as $permission) { foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline(); $options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_')); $descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission; $permissionsArray[$data['name']][] = $permission;
} }
$tabs[] = Tab::make(str($data['name'])->headline()) $tabs[] = Tab::make(str($data['name'])->headline())
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc')) ->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->icon($data['icon']) ->icon($data['icon'])
->schema([ ->schema([
CheckboxList::make($data['name']) CheckboxList::make($data['name'])
@ -121,30 +121,33 @@ class UserResource extends Resource
->alignCenter()->circular() ->alignCenter()->circular()
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)), ->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username') TextColumn::make('username')
->label(trans('server/user.username'))
->searchable(), ->searchable(),
TextColumn::make('email') TextColumn::make('email')
->label(trans('server/user.email'))
->searchable(), ->searchable(),
TextColumn::make('permissions') TextColumn::make('permissions')
->label(trans('server/user.permissions.title'))
->state(fn (User $user) => count($server->subusers->where('user_id', $user->id)->first()->permissions)), ->state(fn (User $user) => count($server->subusers->where('user_id', $user->id)->first()->permissions)),
]) ])
->actions([ ->actions([
DeleteAction::make() DeleteAction::make()
->label('Remove User') ->label(trans('server/user.delete'))
->hidden(fn (User $user) => auth()->user()->id === $user->id) ->hidden(fn (User $user) => auth()->user()->id === $user->id)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) { ->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first(); $subuser = $server->subusers->where('user_id', $user->id)->first();
$subuserDeletionService->handle($subuser, $server); $subuserDeletionService->handle($subuser, $server);
Notification::make() Notification::make()
->title('User Deleted!') ->title(trans('server/user.notification_delete'))
->success() ->success()
->send(); ->send();
}), }),
EditAction::make() EditAction::make()
->label('Edit User') ->label(trans('server/user.edit'))
->hidden(fn (User $user) => auth()->user()->id === $user->id) ->hidden(fn (User $user) => auth()->user()->id === $user->id)
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => 'Editing ' . $user->email) ->modalHeading(fn (User $user) => trans('server/user.editing', ['user' => $user->email]))
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) { ->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first(); $subuser = $server->subusers->where('user_id', $user->id)->first();
@ -158,7 +161,7 @@ class UserResource extends Resource
$subuserUpdateService->handle($subuser, $server, $permissions); $subuserUpdateService->handle($subuser, $server, $permissions);
Notification::make() Notification::make()
->title('User Updated!') ->title(trans('server/user.notification_edit'))
->success() ->success()
->send(); ->send();
@ -185,7 +188,7 @@ class UserResource extends Resource
]), ]),
Actions::make([ Actions::make([
Action::make('assignAll') Action::make('assignAll')
->label('Assign All') ->label(trans('server/user.assign_all'))
->action(function (Set $set) use ($permissionsArray) { ->action(function (Set $set) use ($permissionsArray) {
$permissions = $permissionsArray; $permissions = $permissionsArray;
foreach ($permissions as $key => $value) { foreach ($permissions as $key => $value) {
@ -231,4 +234,9 @@ class UserResource extends Resource
'index' => Pages\ListUsers::route('/'), 'index' => Pages\ListUsers::route('/'),
]; ];
} }
public static function getNavigationLabel(): string
{
return trans('server/user.title');
}
} }

View File

@ -25,6 +25,7 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\IconSize; use Filament\Support\Enums\IconSize;
use Illuminate\Contracts\Support\Htmlable;
class ListUsers extends ListRecords class ListUsers extends ListRecords
{ {
@ -48,14 +49,14 @@ class ListUsers extends ListRecords
foreach ($data['permissions'] as $permission) { foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline(); $options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_')); $descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission; $permissionsArray[$data['name']][] = $permission;
} }
$tabs[] = Tab::make(str($data['name'])->headline()) $tabs[] = Tab::make(str($data['name'])->headline())
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc')) ->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->icon($data['icon']) ->icon($data['icon'])
->schema([ ->schema([
CheckboxList::make($data['name']) CheckboxList::make($data['name'])
@ -72,7 +73,7 @@ class ListUsers extends ListRecords
Actions\CreateAction::make('invite') Actions\CreateAction::make('invite')
->hiddenLabel()->iconButton()->iconSize(IconSize::Large) ->hiddenLabel()->iconButton()->iconSize(IconSize::Large)
->icon('tabler-user-plus') ->icon('tabler-user-plus')
->tooltip('Invite User') ->tooltip(trans('server/user.invite_user'))
->createAnother(false) ->createAnother(false)
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_CREATE, $server))
->form([ ->form([
@ -86,6 +87,7 @@ class ListUsers extends ListRecords
]) ])
->schema([ ->schema([
TextInput::make('email') TextInput::make('email')
->label(trans('server/user.email'))
->email() ->email()
->inlineLabel() ->inlineLabel()
->columnSpan([ ->columnSpan([
@ -97,7 +99,7 @@ class ListUsers extends ListRecords
->required(), ->required(),
assignAll::make([ assignAll::make([
Action::make('assignAll') Action::make('assignAll')
->label('Assign All') ->label(trans('server/user.assign_all'))
->action(function (Set $set, Get $get) use ($permissionsArray) { ->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = $permissionsArray; $permissions = $permissionsArray;
foreach ($permissions as $key => $value) { foreach ($permissions as $key => $value) {
@ -117,8 +119,8 @@ class ListUsers extends ListRecords
->schema($tabs), ->schema($tabs),
]), ]),
]) ])
->modalHeading('Invite User') ->modalHeading(trans('server/user.invite_user'))
->modalSubmitActionLabel('Invite') ->modalSubmitActionLabel(trans('server/user.action'))
->action(function (array $data, SubuserCreationService $service) use ($server) { ->action(function (array $data, SubuserCreationService $service) use ($server) {
$email = strtolower($data['email']); $email = strtolower($data['email']);
@ -140,12 +142,12 @@ class ListUsers extends ListRecords
]); ]);
Notification::make() Notification::make()
->title('User Invited!') ->title(trans('server/user.notification_add'))
->success() ->success()
->send(); ->send();
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->title('Failed') ->title(trans('server/user.notification_failed'))
->body($exception->getMessage()) ->body($exception->getMessage())
->danger() ->danger()
->send(); ->send();
@ -160,4 +162,9 @@ class ListUsers extends ListRecords
{ {
return []; return [];
} }
public function getTitle(): string|Htmlable
{
return trans('server/user.title');
}
} }

View File

@ -80,6 +80,6 @@ class ServerCpuChart extends ChartWidget
public function getHeading(): string public function getHeading(): string
{ {
return 'CPU'; return trans('server/console.labels.cpu');
} }
} }

View File

@ -80,6 +80,6 @@ class ServerMemoryChart extends ChartWidget
public function getHeading(): string public function getHeading(): string
{ {
return 'Memory'; return trans('server/console.labels.memory');
} }
} }

View File

@ -112,6 +112,6 @@ class ServerNetworkChart extends ChartWidget
{ {
$lastData = collect(cache()->get("servers.{$this->server->id}.network"))->last(); $lastData = collect(cache()->get("servers.{$this->server->id}.network"))->last();
return 'Network - ↓' . convert_bytes_to_readable($lastData->rx_bytes ?? 0) . ' - ↑' . convert_bytes_to_readable($lastData->tx_bytes ?? 0); return trans('server/console.labels.network') . ' - ↓' . convert_bytes_to_readable($lastData->rx_bytes ?? 0) . ' - ↑' . convert_bytes_to_readable($lastData->tx_bytes ?? 0);
} }
} }

View File

@ -20,14 +20,14 @@ class ServerOverview extends StatsOverviewWidget
protected function getStats(): array protected function getStats(): array
{ {
return [ return [
SmallStatBlock::make('Name', $this->server->name) SmallStatBlock::make(trans('server/console.labels.name'), $this->server->name)
->copyOnClick(fn () => request()->isSecure()), ->copyOnClick(fn () => request()->isSecure()),
SmallStatBlock::make('Status', $this->status()), SmallStatBlock::make(trans('server/console.labels.status'), $this->status()),
SmallStatBlock::make('Address', $this->server?->allocation->address ?? 'None') SmallStatBlock::make(trans('server/console.labels.address'), $this->server?->allocation->address ?? 'None')
->copyOnClick(fn () => request()->isSecure()), ->copyOnClick(fn () => request()->isSecure()),
SmallStatBlock::make('CPU', $this->cpuUsage()), SmallStatBlock::make(trans('server/console.labels.cpu'), $this->cpuUsage()),
SmallStatBlock::make('Memory', $this->memoryUsage()), SmallStatBlock::make(trans('server/console.labels.memory'), $this->memoryUsage()),
SmallStatBlock::make('Disk', $this->diskUsage()), SmallStatBlock::make(trans('server/console.labels.disk'), $this->diskUsage()),
]; ];
} }

View File

@ -19,6 +19,7 @@ use App\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use App\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest; use App\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use App\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest; use App\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
use App\Http\Requests\Api\Client\Servers\Backups\RenameBackupRequest;
use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\Group;
#[Group('Server - Backup')] #[Group('Server - Backup')]
@ -195,6 +196,35 @@ class BackupController extends ClientApiController
]); ]);
} }
/**
* Rename backup
*
* Updates the name of a backup for a server instance.
*
* @return array<array-key, mixed>
*
* @throws \Throwable
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function rename(RenameBackupRequest $request, Server $server, Backup $backup): array
{
$oldName = $backup->name;
$newName = $request->input('name');
$backup->update(['name' => $newName]);
if ($oldName !== $newName) {
Activity::event('server:backup.rename')
->subject($backup)
->property(['old_name' => $oldName, 'new_name' => $newName])
->log();
}
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/** /**
* Restore backup * Restore backup
* *

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Remote; namespace App\Http\Controllers\Api\Remote;
use App\Models\Node;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\User; use App\Models\User;
@ -14,7 +15,7 @@ class ActivityProcessingController extends Controller
{ {
public function __invoke(ActivityEventRequest $request): void public function __invoke(ActivityEventRequest $request): void
{ {
/** @var \App\Models\Node $node */ /** @var Node $node */
$node = $request->attributes->get('node'); $node = $request->attributes->get('node');
$servers = $node->servers()->whereIn('uuid', $request->servers())->get()->keyBy('uuid'); $servers = $node->servers()->whereIn('uuid', $request->servers())->get()->keyBy('uuid');
@ -22,7 +23,7 @@ class ActivityProcessingController extends Controller
$logs = []; $logs = [];
foreach ($request->input('data') as $datum) { foreach ($request->input('data') as $datum) {
/** @var \App\Models\Server|null $server */ /** @var Server|null $server */
$server = $servers->get($datum['server']); $server = $servers->get($datum['server']);
if (is_null($server) || !Str::startsWith($datum['event'], 'server:')) { if (is_null($server) || !Str::startsWith($datum['event'], 'server:')) {
continue; continue;

View File

@ -2,7 +2,8 @@
namespace App\Http\Controllers\Api\Remote\Servers; namespace App\Http\Controllers\Api\Remote\Servers;
use Illuminate\Http\Request; use App\Enums\ContainerStatus;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server; use App\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -12,11 +13,11 @@ class ServerContainersController extends Controller
/** /**
* Updates the server container's status on the Panel * Updates the server container's status on the Panel
*/ */
public function status(Server $server, Request $request): JsonResponse public function status(ServerRequest $request, Server $server): JsonResponse
{ {
$status = fluent($request->json()->all())->get('data.new_state'); $status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;
cache()->put("servers.$server->uuid.container.status", $status, now()->addHour()); cache()->put("servers.$server->uuid.status", $status, now()->addHour());
return new JsonResponse([]); return new JsonResponse([]);
} }

View File

@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api\Remote\Servers; namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState; use App\Enums\ServerState;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\ActivityLog;
use App\Models\Backup; use App\Models\Backup;
use App\Models\Node;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Server; use App\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -29,7 +32,7 @@ class ServerDetailsController extends Controller
* Returns details about the server that allows daemon to self-recover and ensure * Returns details about the server that allows daemon to self-recover and ensure
* that the state of the server matches the Panel at all times. * that the state of the server matches the Panel at all times.
*/ */
public function __invoke(Server $server): JsonResponse public function __invoke(ServerRequest $request, Server $server): JsonResponse
{ {
return new JsonResponse([ return new JsonResponse([
'settings' => $this->configurationStructureService->handle($server), 'settings' => $this->configurationStructureService->handle($server),
@ -42,7 +45,7 @@ class ServerDetailsController extends Controller
*/ */
public function list(Request $request): ServerConfigurationCollection public function list(Request $request): ServerConfigurationCollection
{ {
/** @var \App\Models\Node $node */ /** @var Node $node */
$node = $request->attributes->get('node'); $node = $request->attributes->get('node');
// Avoid run-away N+1 SQL queries by preloading the relationships that are used // Avoid run-away N+1 SQL queries by preloading the relationships that are used
@ -85,9 +88,9 @@ class ServerDetailsController extends Controller
->get(); ->get();
$this->connection->transaction(function () use ($node, $servers) { $this->connection->transaction(function () use ($node, $servers) {
/** @var \App\Models\Server $server */ /** @var Server $server */
foreach ($servers as $server) { foreach ($servers as $server) {
/** @var \App\Models\ActivityLog|null $activity */ /** @var ActivityLog|null $activity */
$activity = $server->activity->first(); $activity = $server->activity->first();
if (!$activity) { if (!$activity) {
continue; continue;

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Remote\Servers; namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState; use App\Enums\ServerState;
use App\Http\Requests\Api\Remote\ServerRequest;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use App\Models\Server; use App\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -15,14 +16,12 @@ class ServerInstallController extends Controller
/** /**
* Returns installation information for a server. * Returns installation information for a server.
*/ */
public function index(Server $server): JsonResponse public function index(ServerRequest $request, Server $server): JsonResponse
{ {
$egg = $server->egg;
return new JsonResponse([ return new JsonResponse([
'container_image' => $egg->copy_script_container, 'container_image' => $server->egg->copy_script_container,
'entrypoint' => $egg->copy_script_entry, 'entrypoint' => $server->egg->copy_script_entry,
'script' => $egg->copy_script_install, 'script' => $server->egg->copy_script_install,
]); ]);
} }

View File

@ -2,12 +2,12 @@
namespace App\Http\Controllers\Api\Remote\Servers; namespace App\Http\Controllers\Api\Remote\Servers;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server; use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository; use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Models\Allocation; use App\Models\Allocation;
use App\Models\ServerTransfer;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@ -28,14 +28,23 @@ class ServerTransferController extends Controller
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function failure(Server $server): JsonResponse public function failure(ServerRequest $request, Server $server): JsonResponse
{ {
$transfer = $server->transfer; $transfer = $server->transfer;
if (is_null($transfer)) { if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.'); throw new ConflictHttpException('Server is not being transferred.');
} }
return $this->processFailedTransfer($transfer); $this->connection->transaction(function () use ($transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
if ($transfer->new_allocation || $transfer->new_additional_allocations) {
$allocations = array_merge([$transfer->new_allocation], $transfer->new_additional_allocations);
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
}
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
/** /**
@ -43,16 +52,17 @@ class ServerTransferController extends Controller
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function success(Server $server): JsonResponse public function success(ServerRequest $request, Server $server): JsonResponse
{ {
$transfer = $server->transfer; $transfer = $server->transfer;
if (is_null($transfer)) { if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.'); throw new ConflictHttpException('Server is not being transferred.');
} }
$data = []; /** @var Server $server */
/** @var \App\Models\Server $server */ $server = $this->connection->transaction(function () use ($server, $transfer) {
$server = $this->connection->transaction(function () use ($server, $transfer, $data) { $data = [];
if ($transfer->old_allocation || $transfer->old_additional_allocations) { if ($transfer->old_allocation || $transfer->old_additional_allocations) {
$allocations = array_merge([$transfer->old_allocation], $transfer->old_additional_allocations); $allocations = array_merge([$transfer->old_allocation], $transfer->old_additional_allocations);
// Remove the old allocations for the server and re-assign the server to the new // Remove the old allocations for the server and re-assign the server to the new
@ -60,6 +70,7 @@ class ServerTransferController extends Controller
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]); Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
$data['allocation_id'] = $transfer->new_allocation; $data['allocation_id'] = $transfer->new_allocation;
} }
$data['node_id'] = $transfer->new_node; $data['node_id'] = $transfer->new_node;
$server->update($data); $server->update($data);
@ -82,24 +93,4 @@ class ServerTransferController extends Controller
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
/**
* Release all the reserved allocations for this transfer and mark it as failed in
* the database.
*
* @throws \Throwable
*/
protected function processFailedTransfer(ServerTransfer $transfer): JsonResponse
{
$this->connection->transaction(function () use (&$transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
if ($transfer->new_allocation || $transfer->new_additional_allocations) {
$allocations = array_merge([$transfer->new_allocation], $transfer->new_additional_allocations);
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
}
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
} }

View File

@ -2,37 +2,37 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Extensions\OAuth\OAuthSchemaInterface;
use App\Extensions\OAuth\OAuthService; use App\Extensions\OAuth\OAuthService;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\Users\UserUpdateService; use App\Services\Users\UserCreationService;
use Exception; use Exception;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Socialite\Contracts\User as OAuthUser;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
class OAuthController extends Controller class OAuthController extends Controller
{ {
public function __construct( public function __construct(
private readonly AuthManager $auth, private readonly UserCreationService $userCreation,
private readonly UserUpdateService $updateService, private readonly OAuthService $oauthService,
private readonly OAuthService $oauthService
) {} ) {}
/** /**
* Redirect user to the OAuth provider * Redirect user to the OAuth provider
*/ */
public function redirect(string $driver): RedirectResponse public function redirect(string $driver): SymfonyRedirectResponse|RedirectResponse
{ {
// Driver is disabled - redirect to normal login
if (!$this->oauthService->get($driver)->isEnabled()) { if (!$this->oauthService->get($driver)->isEnabled()) {
return redirect()->route('auth.login'); return redirect()->route('auth.login');
} }
return Socialite::with($driver)->redirect(); return Socialite::driver($driver)->redirect();
} }
/** /**
@ -40,8 +40,9 @@ class OAuthController extends Controller
*/ */
public function callback(Request $request, string $driver): RedirectResponse public function callback(Request $request, string $driver): RedirectResponse
{ {
// Driver is disabled - redirect to normal login $driver = $this->oauthService->get($driver);
if (!$this->oauthService->get($driver)?->isEnabled()) {
if (!$driver || !$driver->isEnabled()) {
return redirect()->route('auth.login'); return redirect()->route('auth.login');
} }
@ -49,43 +50,89 @@ class OAuthController extends Controller
if ($request->get('error')) { if ($request->get('error')) {
report($request->get('error_description') ?? $request->get('error')); report($request->get('error_description') ?? $request->get('error'));
Notification::make() return $this->errorRedirect($request->get('error'));
->title('Something went wrong')
->body($request->get('error'))
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
} }
$oauthUser = Socialite::driver($driver)->user(); $oauthUser = Socialite::driver($driver->getId())->user();
// User is already logged in and wants to link a new OAuth Provider
if ($request->user()) { if ($request->user()) {
$oauth = $request->user()->oauth; $this->linkUser($request->user(), $driver, $oauthUser);
$oauth[$driver] = $oauthUser->getId();
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return redirect(EditProfile::getUrl(['tab' => '-oauth-tab'], panel: 'app')); return redirect(EditProfile::getUrl(['tab' => '-oauth-tab'], panel: 'app'));
} }
try { $user = User::whereJsonContains('oauth->'. $driver->getId(), $oauthUser->getId())->first();
$user = User::query()->whereJsonContains('oauth->'. $driver, $oauthUser->getId())->firstOrFail(); if ($user) {
return $this->loginUser($user);
$this->auth->guard()->login($user, true);
} catch (Exception) {
// No user found - redirect to normal login
Notification::make()
->title('No linked User found')
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
} }
return $this->handleMissingUser($driver, $oauthUser);
}
private function linkUser(User $user, OAuthSchemaInterface $driver, OAuthUser $oauthUser): User
{
$oauth = $user->oauth;
$oauth[$driver->getId()] = $oauthUser->getId();
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
private function handleMissingUser(OAuthSchemaInterface $driver, OAuthUser $oauthUser): RedirectResponse
{
$email = $oauthUser->getEmail();
if (!$email) {
return $this->errorRedirect();
}
$user = User::whereEmail($email)->first();
if ($user) {
if (!$driver->shouldLinkMissingUsers()) {
return $this->errorRedirect();
}
$user = $this->linkUser($user, $driver, $oauthUser);
} else {
if (!$driver->shouldCreateMissingUsers()) {
return $this->errorRedirect();
}
try {
$user = $this->userCreation->handle([
'username' => $oauthUser->getNickname(),
'email' => $email,
'oauth' => [
$driver->getId() => $oauthUser->getId(),
],
]);
} catch (Exception $exception) {
report($exception);
return $this->errorRedirect();
}
}
return $this->loginUser($user);
}
private function loginUser(User $user): RedirectResponse
{
auth()->guard()->login($user, true);
return redirect('/'); return redirect('/');
} }
private function errorRedirect(?string $error = null): RedirectResponse
{
Notification::make()
->title($error ? 'Something went wrong' : 'No linked User found')
->body($error)
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
}
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Backups;
use App\Models\Permission;
use App\Http\Requests\Api\Client\ClientApiRequest;
class RenameBackupRequest extends ClientApiRequest
{
public function permission(): string
{
return Permission::ACTION_BACKUP_DELETE;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
];
}
}

View File

@ -2,15 +2,8 @@
namespace App\Http\Requests\Api\Remote; namespace App\Http\Requests\Api\Remote;
use Illuminate\Foundation\Http\FormRequest; class InstallationDataRequest extends ServerRequest
class InstallationDataRequest extends FormRequest
{ {
public function authorize(): bool
{
return true;
}
/** /**
* @return array<string, string|string[]> * @return array<string, string|string[]>
*/ */

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Api\Remote;
use App\Models\Node;
use App\Models\Server;
use Illuminate\Foundation\Http\FormRequest;
class ServerRequest extends FormRequest
{
public function authorize(): bool
{
/** @var Node $node */
$node = $this->attributes->get('node');
/** @var ?Server $server */
$server = $this->route()->parameter('server');
return $server && $server->node_id === $node->id;
}
}

View File

@ -28,14 +28,14 @@ class ProcessWebhook implements ShouldQueue
public function handle(): void public function handle(): void
{ {
$data = $this->data[0]; $data = $this->data[0] ?? [];
if (count($data) === 1) {
$data = reset($data);
}
$data = is_array($data) ? $data : (json_decode($data, true) ?? []);
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
if ($this->webhookConfiguration->type === WebhookType::Discord) { if ($this->webhookConfiguration->type === WebhookType::Discord) {
$data = array_merge(
is_array($data) ? $data : json_decode($data, true),
['event' => $this->webhookConfiguration->transformClassName($this->eventName)]
);
$payload = json_encode($this->webhookConfiguration->payload); $payload = json_encode($this->webhookConfiguration->payload);
$tmp = $this->webhookConfiguration->replaceVars($data, $payload); $tmp = $this->webhookConfiguration->replaceVars($data, $payload);
$data = json_decode($tmp, true); $data = json_decode($tmp, true);
@ -53,9 +53,10 @@ class ProcessWebhook implements ShouldQueue
} }
try { try {
$customHeaders = $this->webhookConfiguration->headers;
$headers = []; $headers = [];
if ($this->webhookConfiguration->type === WebhookType::Regular && $customHeaders = $this->webhookConfiguration->headers) { foreach ($customHeaders as $key => $value) {
$headers = array_merge(['X-Webhook-Event', $this->eventName], $customHeaders); $headers[$key] = $this->webhookConfiguration->replaceVars($data, $value);
} }
Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw(); Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw();

View File

@ -4,25 +4,32 @@ namespace App\Livewire;
use Closure; use Closure;
use Filament\Notifications\Concerns; use Filament\Notifications\Concerns;
use Filament\Support\Concerns\EvaluatesClosures; use Filament\Support\Components\ViewComponent;
use Illuminate\Support\Str; use Illuminate\Contracts\Support\Arrayable;
use Livewire\Wireable;
final class AlertBanner implements Wireable final class AlertBanner extends ViewComponent implements Arrayable
{ {
use Concerns\HasBody; use Concerns\HasBody;
use Concerns\HasIcon; use Concerns\HasIcon;
use Concerns\HasId; use Concerns\HasId;
use Concerns\HasStatus; use Concerns\HasStatus;
use Concerns\HasTitle; use Concerns\HasTitle;
use EvaluatesClosures;
protected bool|Closure $closable = false; protected bool|Closure $closable = false;
public static function make(?string $id = null): AlertBanner protected string $view = 'livewire.alerts.alert-banner';
protected string $viewIdentifier = 'alert-banner';
public function __construct(string $id)
{ {
$static = new self(); $this->id($id);
$static->id($id ?? Str::orderedUuid()); }
public static function make(string $id): AlertBanner
{
$static = new self($id);
$static->configure();
return $static; return $static;
} }
@ -30,7 +37,7 @@ final class AlertBanner implements Wireable
/** /**
* @return array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool} * @return array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool}
*/ */
public function toLivewire(): array public function toArray(): array
{ {
return [ return [
'id' => $this->getId(), 'id' => $this->getId(),
@ -42,15 +49,18 @@ final class AlertBanner implements Wireable
]; ];
} }
public static function fromLivewire(mixed $value): AlertBanner /**
* @param array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool} $data
*/
public static function fromArray(array $data): AlertBanner
{ {
$static = AlertBanner::make($value['id']); $static = AlertBanner::make($data['id']);
$static->title($value['title']); $static->title($data['title']);
$static->body($value['body']); $static->body($data['body']);
$static->status($value['status']); $static->status($data['status']);
$static->icon($value['icon']); $static->icon($data['icon']);
$static->closable($value['closeable']); $static->closable($data['closeable']);
return $static; return $static;
} }
@ -69,7 +79,7 @@ final class AlertBanner implements Wireable
public function send(): AlertBanner public function send(): AlertBanner
{ {
session()->push('alert-banners', $this->toLivewire()); session()->push('alert-banners', $this->toArray());
return $this; return $this;
} }

View File

@ -2,18 +2,18 @@
namespace App\Livewire; namespace App\Livewire;
use Filament\Notifications\Collection;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
class AlertBannerContainer extends Component class AlertBannerContainer extends Component
{ {
/** @var array<AlertBanner> */ public Collection $alertBanners;
public array $alertBanners;
public function mount(): void public function mount(): void
{ {
$this->alertBanners = []; $this->alertBanners = new Collection();
$this->pullFromSession(); $this->pullFromSession();
} }
@ -21,15 +21,16 @@ class AlertBannerContainer extends Component
public function pullFromSession(): void public function pullFromSession(): void
{ {
foreach (session()->pull('alert-banners', []) as $alertBanner) { foreach (session()->pull('alert-banners', []) as $alertBanner) {
$alertBanner = AlertBanner::fromLivewire($alertBanner); $alertBanner = AlertBanner::fromArray($alertBanner);
$this->alertBanners[$alertBanner->getId()] = $alertBanner; $this->alertBanners->put($alertBanner->getId(), $alertBanner);
} }
} }
public function remove(string $id): void public function remove(string $id): void
{ {
$alertBanners = &$this->alertBanners; if ($this->alertBanners->has($id)) {
unset($alertBanners[$id]); $this->alertBanners->forget($id);
}
} }
public function render(): View public function render(): View

View File

@ -24,37 +24,40 @@ class ServerEntry extends Component
style="background-color: #D97706;"> style="background-color: #D97706;">
</div> </div>
<div class="flex-1 dark:bg-gray-850 dark:text-white rounded-lg overflow-hidden p-2"> <div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
<div class="flex items-center mb-5 gap-2"> <div class="flex items-center mb-5 gap-2">
<x-filament::loading-indicator class="h-5 w-5" /> <x-filament::loading-indicator class="h-6 w-6" />
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
{{ $server->name }} {{ $server->name }}
<span class="dark:text-gray-400">
({{ trans('server/dashboard.loading') }})
</span>
</h2> </h2>
</div> </div>
<div class="flex justify-between text-center"> <div class="flex justify-between text-center items-center gap-4">
<div> <div>
<p class="text-sm dark:text-gray-400">CPU</p> <p class="text-sm dark:text-gray-400">{{ trans('server/dashboard.cpu') }}</p>
<p class="text-md font-semibold">{{ Number::format(0, precision: 2, locale: auth()->user()->language ?? 'en') . '%' }}</p> <p class="text-md font-semibold">{{ Number::format(0, precision: 2, locale: auth()->user()->language ?? 'en') . '%' }}</p>
<hr class="p-0.5"> <hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('cpu', type: \App\Enums\ServerResourceType::Percentage, limit: true) }}</p> <p class="text-xs dark:text-gray-400">{{ $server->formatResource(\App\Enums\ServerResourceType::CPULimit) }}</p>
</div> </div>
<div> <div>
<p class="text-sm dark:text-gray-400">Memory</p> <p class="text-sm dark:text-gray-400">{{ trans('server/dashboard.memory') }}</p>
<p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p> <p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p>
<hr class="p-0.5"> <hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('memory', limit: true) }}</p> <p class="text-xs dark:text-gray-400">{{ $server->formatResource(\App\Enums\ServerResourceType::MemoryLimit) }}</p>
</div> </div>
<div> <div>
<p class="text-sm dark:text-gray-400">Disk</p> <p class="text-sm dark:text-gray-400">{{ trans('server/dashboard.disk') }}</p>
<p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p> <p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p>
<hr class="p-0.5"> <hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('disk', limit: true) }}</p> <p class="text-xs dark:text-gray-400">{{ $server->formatResource(\App\Enums\ServerResourceType::DiskLimit) }}</p>
</div> </div>
<div class="hidden sm:block"> <div class="hidden sm:block">
<p class="text-sm dark:text-gray-400">Network</p> <p class="text-sm dark:text-gray-400">{{ trans('server/dashboard.network') }}</p>
<hr class="p-0.5"> <hr class="p-0.5">
<p class="text-md font-semibold">{{ $server->allocation?->address ?? 'None' }} </p> <p class="text-md font-semibold">{{ $server->allocation?->address ?? trans('server/dashboard.none') }} </p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -69,7 +69,7 @@ class Egg extends Model implements Validatable
/** /**
* Defines the current egg export version. * Defines the current egg export version.
*/ */
public const EXPORT_VERSION = 'PLCN_v1'; public const EXPORT_VERSION = 'PLCN_v2';
/** /**
* Fields that are not mass assignable. * Fields that are not mass assignable.

View File

@ -194,7 +194,7 @@ class File extends Model
$message = str('Node connection failed'); $message = str('Node connection failed');
} }
AlertBanner::make() AlertBanner::make('files_node_error')
->title('Could not load files!') ->title('Could not load files!')
->body($message->toString()) ->body($message->toString())
->danger() ->danger()

View File

@ -462,17 +462,15 @@ class Server extends Model implements Validatable
}); });
} }
public function formatResource(string $resourceKey, bool $limit = false, ServerResourceType $type = ServerResourceType::Unit, int $precision = 2): string public function formatResource(ServerResourceType $resourceType): string
{ {
$resourceAmount = $this->{$resourceKey} ?? 0; $resourceAmount = $resourceType->getResourceAmount($this);
if (!$limit) {
$resourceAmount = $this->retrieveResources()[$resourceKey] ?? 0;
}
if ($type === ServerResourceType::Time) { if ($resourceType->isTime()) {
if ($this->isSuspended()) { if (!is_null($this->status)) {
return 'Suspended'; return $this->status->getLabel();
} }
if ($resourceAmount === 0) { if ($resourceAmount === 0) {
return ContainerStatus::Offline->getLabel(); return ContainerStatus::Offline->getLabel();
} }
@ -480,20 +478,16 @@ class Server extends Model implements Validatable
return now()->subMillis($resourceAmount)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 4); return now()->subMillis($resourceAmount)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 4);
} }
if ($resourceAmount === 0 & $limit) { if ($resourceAmount === 0 & $resourceType->isLimit()) {
// Unlimited symbol
return "\u{221E}"; return "\u{221E}";
} }
if ($type === ServerResourceType::Percentage) { if ($resourceType->isPercentage()) {
return Number::format($resourceAmount, precision: $precision, locale: auth()->user()->language ?? 'en') . '%'; return Number::format($resourceAmount, precision: 2, locale: auth()->user()->language ?? 'en') . '%';
} }
// Our current limits are set in MB return convert_bytes_to_readable($resourceAmount, base: 3);
if ($limit) {
$resourceAmount *= 2 ** 20;
}
return convert_bytes_to_readable($resourceAmount, decimals: $precision, base: 3);
} }
public function condition(): Attribute public function condition(): Attribute

View File

@ -101,24 +101,24 @@ class AppServiceProvider extends ServiceProvider
'blurple' => Color::hex('#5865F2'), 'blurple' => Color::hex('#5865F2'),
]); ]);
FilamentView::registerRenderHook(
PanelsRenderHook::HEAD_START,
fn () => Blade::render('filament.layouts.header')
);
FilamentView::registerRenderHook( FilamentView::registerRenderHook(
PanelsRenderHook::PAGE_START, PanelsRenderHook::PAGE_START,
fn () => Blade::render('@livewire(\App\Livewire\AlertBannerContainer::class)'), fn () => Blade::render('@livewire(\App\Livewire\AlertBannerContainer::class)'),
); );
FilamentView::registerRenderHook( FilamentView::registerRenderHook(
PanelsRenderHook::BODY_END, PanelsRenderHook::FOOTER,
fn () => Blade::render('filament.layouts.body-end'), fn () => Blade::render('filament.layouts.footer'),
); );
FilamentView::registerRenderHook( FilamentView::registerRenderHook(
PanelsRenderHook::FOOTER, PanelsRenderHook::STYLES_BEFORE,
fn () => Blade::render('filament.layouts.footer'), fn () => Blade::render("@vite(['resources/css/app.css'])")
);
FilamentView::registerRenderHook(
PanelsRenderHook::SCRIPTS_AFTER,
fn () => Blade::render("@vite(['resources/js/app.js'])"),
); );
on('dehydrate', function (Component $component) { on('dehydrate', function (Component $component) {

View File

@ -2,45 +2,21 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem; use Filament\Navigation\MenuItem;
use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationGroup;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
{ {
public function panel(Panel $panel): Panel public function panel(Panel $panel): Panel
{ {
return $panel return parent::panel($panel)
->default()
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->homeUrl('/') ->homeUrl('/')
->spa()
->databaseNotifications()
->breadcrumbs(false) ->breadcrumbs(false)
->brandName(config('app.name', 'Pelican')) ->sidebarCollapsibleOnDesktop()
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
'profile' => MenuItem::make() 'profile' => MenuItem::make()
->label(fn () => trans('filament-panels::pages/auth/edit-profile.label')) ->label(fn () => trans('filament-panels::pages/auth/edit-profile.label'))
@ -58,25 +34,8 @@ class AdminPanelProvider extends PanelProvider
->collapsible(false), ->collapsible(false),
NavigationGroup::make(fn () => trans('admin/dashboard.advanced')), NavigationGroup::make(fn () => trans('admin/dashboard.advanced')),
]) ])
->sidebarCollapsibleOnDesktop()
->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources') ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages')
->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\Filament\\Admin\\Widgets') ->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\Filament\\Admin\\Widgets');
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
])
->authMiddleware([
Authenticate::class,
]);
} }
} }

View File

@ -2,68 +2,27 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem; use Filament\Navigation\MenuItem;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AppPanelProvider extends PanelProvider class AppPanelProvider extends PanelProvider
{ {
public function panel(Panel $panel): Panel public function panel(Panel $panel): Panel
{ {
return $panel return parent::panel($panel)
->id('app') ->id('app')
->spa() ->default()
->databaseNotifications()
->breadcrumbs(false) ->breadcrumbs(false)
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->navigation(false) ->navigation(false)
->profile(EditProfile::class, false)
->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
MenuItem::make() MenuItem::make()
->label('Admin') ->label(trans('profile.admin'))
->url('/admin') ->url('/admin')
->icon('tabler-arrow-forward') ->icon('tabler-arrow-forward')
->sort(5) ->sort(5)
->visible(fn (): bool => auth()->user()->canAccessPanel(Filament::getPanel('admin'))), ->visible(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin'))),
]) ])
->discoverResources(in: app_path('Filament/App/Resources'), for: 'App\\Filament\\App\\Resources') ->discoverResources(in: app_path('Filament/App/Resources'), for: 'App\\Filament\\App\\Resources');
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
])
->authMiddleware([
Authenticate::class,
]);
} }
} }

View File

@ -0,0 +1,55 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Panel;
use Filament\PanelProvider as BasePanelProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
abstract class PanelProvider extends BasePanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->spa()
->databaseNotifications()
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(fn () => auth()->user()->getCustomization()['top_navigation'] ?? false)
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->profile(EditProfile::class, false)
->login(Login::class)
->passwordReset()
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@ -3,48 +3,24 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\App\Resources\ServerResource\Pages\ListServers; use App\Filament\App\Resources\ServerResource\Pages\ListServers;
use App\Filament\Pages\Auth\Login;
use App\Filament\Admin\Resources\ServerResource\Pages\EditServer; use App\Filament\Admin\Resources\ServerResource\Pages\EditServer;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\Activity\ServerSubject; use App\Http\Middleware\Activity\ServerSubject;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use App\Models\Server; use App\Models\Server;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem; use Filament\Navigation\MenuItem;
use Filament\Navigation\NavigationItem; use Filament\Navigation\NavigationItem;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ServerPanelProvider extends PanelProvider class ServerPanelProvider extends PanelProvider
{ {
public function panel(Panel $panel): Panel public function panel(Panel $panel): Panel
{ {
return $panel return parent::panel($panel)
->id('server') ->id('server')
->path('server') ->path('server')
->homeUrl('/') ->homeUrl('/')
->spa()
->databaseNotifications()
->tenant(Server::class) ->tenant(Server::class)
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
'profile' => MenuItem::make() 'profile' => MenuItem::make()
->label(fn () => trans('filament-panels::pages/auth/edit-profile.label')) ->label(fn () => trans('filament-panels::pages/auth/edit-profile.label'))
@ -55,14 +31,14 @@ class ServerPanelProvider extends PanelProvider
->url(fn () => ListServers::getUrl(panel: 'app')) ->url(fn () => ListServers::getUrl(panel: 'app'))
->sort(6), ->sort(6),
MenuItem::make() MenuItem::make()
->label('Admin') ->label(trans('profile.admin'))
->icon('tabler-arrow-forward') ->icon('tabler-arrow-forward')
->url(fn () => Filament::getPanel('admin')->getUrl()) ->url(fn () => Filament::getPanel('admin')->getUrl())
->sort(5) ->sort(5)
->visible(fn (): bool => auth()->user()->canAccessPanel(Filament::getPanel('admin'))), ->visible(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin'))),
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make('Open in Admin') NavigationItem::make(trans('server/console.open_in_admin'))
->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin')) ->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin'))
->visible(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin')) && auth()->user()->can('view server', Filament::getTenant())) ->visible(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin')) && auth()->user()->can('view server', Filament::getTenant()))
->icon('tabler-arrow-back') ->icon('tabler-arrow-back')
@ -72,21 +48,7 @@ class ServerPanelProvider extends PanelProvider
->discoverPages(in: app_path('Filament/Server/Pages'), for: 'App\\Filament\\Server\\Pages') ->discoverPages(in: app_path('Filament/Server/Pages'), for: 'App\\Filament\\Server\\Pages')
->discoverWidgets(in: app_path('Filament/Server/Widgets'), for: 'App\\Filament\\Server\\Widgets') ->discoverWidgets(in: app_path('Filament/Server/Widgets'), for: 'App\\Filament\\Server\\Widgets')
->middleware([ ->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
ServerSubject::class, ServerSubject::class,
])
->authMiddleware([
Authenticate::class,
]); ]);
} }
} }

View File

@ -20,7 +20,14 @@ class EggConfigurationService
* @return array{ * @return array{
* startup: array{done: string[], user_interaction: string[], strip_ansi: bool}, * startup: array{done: string[], user_interaction: string[], strip_ansi: bool},
* stop: array{type: string, value: string}, * stop: array{type: string, value: string},
* configs: array<mixed> * configs: list<array{
* file: string,
* replace: list<array{
* match: string,
* if_value?: string,
* replace_with: string
* }>
* }>
* } * }
*/ */
public function handle(Server $server): array public function handle(Server $server): array
@ -81,9 +88,10 @@ class EggConfigurationService
} }
/** /**
* @return array<mixed> * @param array<string, mixed> $configs
* @return array<int, array<string, mixed>>
*/ */
protected function replacePlaceholders(Server $server, object $configs): array protected function replacePlaceholders(Server $server, object|array $configs): array
{ {
// Get the legacy configuration structure for the server so that we // Get the legacy configuration structure for the server so that we
// can property map the egg placeholders to values. // can property map the egg placeholders to values.

View File

@ -2,17 +2,19 @@
namespace App\Services\Eggs\Sharing; namespace App\Services\Eggs\Sharing;
use App\Enums\EggFormat;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\Egg; use App\Models\Egg;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use App\Models\EggVariable; use App\Models\EggVariable;
use Symfony\Component\Yaml\Yaml;
class EggExporterService class EggExporterService
{ {
/** /**
* Return a JSON representation of an egg and its variables. * Return a JSON or YAML representation of an egg and its variables.
*/ */
public function handle(int $egg): string public function handle(int $egg, EggFormat $format): string
{ {
$egg = Egg::with(['scriptFrom', 'configFrom', 'variables'])->findOrFail($egg); $egg = Egg::with(['scriptFrom', 'configFrom', 'variables'])->findOrFail($egg);
@ -30,9 +32,7 @@ class EggExporterService
'tags' => $egg->tags, 'tags' => $egg->tags,
'features' => $egg->features, 'features' => $egg->features,
'docker_images' => $egg->docker_images, 'docker_images' => $egg->docker_images,
'file_denylist' => Collection::make($egg->inherit_file_denylist)->filter(function ($value) { 'file_denylist' => Collection::make($egg->inherit_file_denylist)->filter(fn ($v) => !empty($v))->values(),
return !empty($value);
}),
'startup' => $egg->startup, 'startup' => $egg->startup,
'config' => [ 'config' => [
'files' => $egg->inherit_config_files, 'files' => $egg->inherit_config_files,
@ -50,9 +50,50 @@ class EggExporterService
'variables' => $egg->variables->map(function (EggVariable $eggVariable) { 'variables' => $egg->variables->map(function (EggVariable $eggVariable) {
return Collection::make($eggVariable->toArray()) return Collection::make($eggVariable->toArray())
->except(['id', 'egg_id', 'created_at', 'updated_at']); ->except(['id', 'egg_id', 'created_at', 'updated_at']);
}), })->values()->toArray(),
]; ];
return json_encode($struct, JSON_PRETTY_PRINT); return match ($format) {
EggFormat::JSON => json_encode($struct, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
EggFormat::YAML => Yaml::dump($this->yamlExport($struct), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_OBJECT_AS_MAP),
};
}
protected function yamlExport(mixed $data): mixed
{
if ($data instanceof Collection) {
$data = $data->all();
}
if (is_string($data)) {
$decoded = json_decode($data, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $this->yamlExport($decoded);
}
return str_replace(["\r\n", '\\r\\n', '\\n'], "\n", $data);
}
if (is_array($data)) {
$result = [];
foreach ($data as $key => $value) {
if (
is_string($value) &&
strtolower($key) === 'description' &&
(str_contains($value, "\n") || strlen($value) > 80)
) {
$value = wordwrap($value, 100, "\n");
} else {
$value = $this->yamlExport($value);
}
$result[$key] = $value;
}
return $result;
}
return $data;
} }
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\Eggs\Sharing; namespace App\Services\Eggs\Sharing;
use App\Exceptions\Service\InvalidFileUploadException; use App\Exceptions\Service\InvalidFileUploadException;
use JsonException;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use App\Models\Egg; use App\Models\Egg;
@ -11,6 +12,9 @@ use App\Models\EggVariable;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use stdClass;
use Symfony\Component\Yaml\Yaml;
use Throwable;
class EggImporterService class EggImporterService
{ {
@ -28,9 +32,9 @@ class EggImporterService
public function __construct(protected ConnectionInterface $connection) {} public function __construct(protected ConnectionInterface $connection) {}
/** /**
* Take an uploaded JSON file and parse it into a new egg. * Take an uploaded JSON or YAML file and parse it into a new egg.
* *
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable * @throws InvalidFileUploadException|Throwable
*/ */
public function fromFile(UploadedFile $file, ?Egg $egg = null): Egg public function fromFile(UploadedFile $file, ?Egg $egg = null): Egg
{ {
@ -46,7 +50,6 @@ class EggImporterService
'copy_script_from' => null, 'copy_script_from' => null,
]); ]);
// Don't check for this anymore
for ($i = 0; $i < count($parsed['variables']); $i++) { for ($i = 0; $i < count($parsed['variables']); $i++) {
unset($parsed['variables'][$i]['field_type']); unset($parsed['variables'][$i]['field_type']);
} }
@ -54,7 +57,6 @@ class EggImporterService
$egg = $this->fillFromParsed($egg, $parsed); $egg = $this->fillFromParsed($egg, $parsed);
$egg->save(); $egg->save();
// Update existing variables or create new ones.
foreach ($parsed['variables'] ?? [] as $variable) { foreach ($parsed['variables'] ?? [] as $variable) {
EggVariable::unguarded(function () use ($egg, $variable) { EggVariable::unguarded(function () use ($egg, $variable) {
$variable['rules'] = is_array($variable['rules']) ? $variable['rules'] : explode('|', $variable['rules']); $variable['rules'] = is_array($variable['rules']) ? $variable['rules'] : explode('|', $variable['rules']);
@ -66,7 +68,6 @@ class EggImporterService
} }
$imported = array_map(fn ($value) => $value['env_variable'], $parsed['variables'] ?? []); $imported = array_map(fn ($value) => $value['env_variable'], $parsed['variables'] ?? []);
$egg->variables()->whereNotIn('env_variable', $imported)->delete(); $egg->variables()->whereNotIn('env_variable', $imported)->delete();
return $egg->refresh(); return $egg->refresh();
@ -74,31 +75,39 @@ class EggImporterService
} }
/** /**
* Take an url and parse it into a new egg or update an existing one. * Take a URL (YAML or JSON) and parse it into a new egg or update an existing one.
* *
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable * @throws InvalidFileUploadException|Throwable
*/ */
public function fromUrl(string $url, ?Egg $egg = null): Egg public function fromUrl(string $url, ?Egg $egg = null): Egg
{ {
$info = pathinfo($url); $info = pathinfo($url);
$extension = strtolower($info['extension']);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed(); $tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
$tmpPath = $tmpDir->path($info['basename']); $tmpPath = $tmpDir->path($info['basename']);
if (!file_put_contents($tmpPath, file_get_contents($url))) { $fileContents = @file_get_contents($url);
throw new InvalidFileUploadException('Could not write temporary file.');
if (!$fileContents || !file_put_contents($tmpPath, $fileContents)) {
throw new InvalidFileUploadException('Could not download or write temporary file.');
} }
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'), $egg); $mime = match ($extension) {
'yaml', 'yml' => 'application/yaml',
'json' => 'application/json',
default => throw new InvalidFileUploadException('Unsupported file format.'),
};
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], $mime), $egg);
} }
/** /**
* Takes an uploaded file and parses out the egg configuration from within. * Takes an uploaded file and parses out the egg configuration from within.
* *
* @todo replace with DTO
*
* @return array<array-key, mixed> * @return array<array-key, mixed>
* *
* @throws \App\Exceptions\Service\InvalidFileUploadException * @throws InvalidFileUploadException|JsonException
*/ */
protected function parseFile(UploadedFile $file): array protected function parseFile(UploadedFile $file): array
{ {
@ -106,30 +115,56 @@ class EggImporterService
throw new InvalidFileUploadException('The selected file was not uploaded successfully'); throw new InvalidFileUploadException('The selected file was not uploaded successfully');
} }
$extension = strtolower($file->getClientOriginalExtension());
$mime = $file->getMimeType();
try { try {
$parsed = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); $content = $file->getContent();
} catch (\JsonException $exception) {
throw new InvalidFileUploadException('Could not read JSON file: ' . $exception->getMessage()); $parsed = match (true) {
in_array($extension, ['yaml', 'yml']),
str_contains($mime, 'yaml') => Yaml::parse($content),
default => json_decode($content, true, 512, JSON_THROW_ON_ERROR),
};
} catch (Throwable $e) {
throw new InvalidFileUploadException('File parse failed: ' . $e->getMessage());
} }
$version = $parsed['meta']['version'] ?? ''; $version = $parsed['meta']['version'] ?? '';
$parsed = match ($version) { $parsed = match ($version) {
'PTDL_v1' => $this->convertToV2($parsed), 'PTDL_v1' => $this->convertToV2($parsed),
'PTDL_v2' => $parsed, 'PTDL_v2', 'PLCN_v1', 'PLCN_v2' => $parsed,
'PLCN_v1' => $parsed, default => throw new InvalidFileUploadException('The file format is not recognized.'),
default => throw new InvalidFileUploadException('The JSON file provided is not in a format that can be recognized.')
}; };
// Make sure we only use recent variable format from now on if (isset($parsed['config']) && (is_array($parsed['config']) || $parsed['config'] instanceof stdClass)) {
if (array_get($parsed['config'], 'files')) { $parsed['config'] = (array) $parsed['config'];
$parsed['config']['files'] = str_replace( foreach ($parsed['config'] as $key => $value) {
array_keys(self::UPGRADE_VARIABLES), if (is_array($value) || $value instanceof stdClass) {
array_values(self::UPGRADE_VARIABLES), $parsed['config'][$key] = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$parsed['config']['files'], }
);
if ($key === 'files' && is_string($parsed['config'][$key])) {
$parsed['config'][$key] = str_replace(
array_keys(self::UPGRADE_VARIABLES),
array_values(self::UPGRADE_VARIABLES),
$parsed['config'][$key]
);
}
}
} }
if (isset($parsed['scripts']['installation']) && (is_array($parsed['scripts']['installation']) || $parsed['scripts']['installation'] instanceof stdClass)) {
$parsed['scripts']['installation'] = (array) $parsed['scripts']['installation'];
foreach ($parsed['scripts']['installation'] as $key => $value) {
if (is_array($value) || $value instanceof stdClass) {
$parsed['scripts']['installation'][$key] = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
}
}
// Reserved env var name handling
[$forbidden, $allowed] = collect($parsed['variables']) [$forbidden, $allowed] = collect($parsed['variables'])
->map(fn ($variable) => array_merge( ->map(fn ($variable) => array_merge(
$variable, $variable,
@ -155,22 +190,7 @@ class EggImporterService
} }
/** /**
* Fills the provided model with the parsed JSON data. * @param array<string, mixed> $parsed
*
* @param array{
* name: string,
* description: string,
* tags: string[],
* features: string[],
* docker_images: string[],
* file_denylist: string[],
* meta: array{update_url: string},
* config: array{files: string, startup: string, logs: string, stop: string},
* startup: string,
* scripts: array{
* installation: array{script: string, entrypoint: string, container: string},
* },
* } $parsed
*/ */
protected function fillFromParsed(Egg $model, array $parsed): Egg protected function fillFromParsed(Egg $model, array $parsed): Egg
{ {
@ -182,9 +202,9 @@ class EggImporterService
'docker_images' => Arr::get($parsed, 'docker_images'), 'docker_images' => Arr::get($parsed, 'docker_images'),
'file_denylist' => Collection::make(Arr::get($parsed, 'file_denylist'))->filter(fn ($value) => !empty($value)), 'file_denylist' => Collection::make(Arr::get($parsed, 'file_denylist'))->filter(fn ($value) => !empty($value)),
'update_url' => Arr::get($parsed, 'meta.update_url'), 'update_url' => Arr::get($parsed, 'meta.update_url'),
'config_files' => Arr::get($parsed, 'config.files'), 'config_files' => json_encode(json_decode(Arr::get($parsed, 'config.files')), JSON_PRETTY_PRINT),
'config_startup' => Arr::get($parsed, 'config.startup'), 'config_startup' => json_encode(json_decode(Arr::get($parsed, 'config.startup')), JSON_PRETTY_PRINT),
'config_logs' => Arr::get($parsed, 'config.logs'), 'config_logs' => json_encode(json_decode(Arr::get($parsed, 'config.logs')), JSON_PRETTY_PRINT),
'config_stop' => Arr::get($parsed, 'config.stop'), 'config_stop' => Arr::get($parsed, 'config.stop'),
'startup' => Arr::get($parsed, 'startup'), 'startup' => Arr::get($parsed, 'startup'),
'script_install' => Arr::get($parsed, 'scripts.installation.script'), 'script_install' => Arr::get($parsed, 'scripts.installation.script'),
@ -194,17 +214,11 @@ class EggImporterService
} }
/** /**
* Converts a PTDL_V1 egg into the expected PTDL_V2 egg format. This just handles * @param array<string, mixed> $parsed
* the "docker_images" field potentially not being present, and not being in the * @return array<string, mixed>
* expected "key => value" format.
*
* @param array{images?: string[], image?: string, field_type?: string, docker_images?: array<array-key, string>} $parsed
* @return array<array-key, mixed>
*/ */
protected function convertToV2(array $parsed): array protected function convertToV2(array $parsed): array
{ {
// Maintain backwards compatability for eggs that are still using the old single image
// string format. New eggs can provide an array of Docker images that can be used.
if (!isset($parsed['images'])) { if (!isset($parsed['images'])) {
$images = [Arr::get($parsed, 'image') ?? 'nil']; $images = [Arr::get($parsed, 'image') ?? 'nil'];
} else { } else {

View File

@ -2,16 +2,16 @@
namespace App\Services\Nodes; namespace App\Services\Nodes;
use App\Extensions\Lcobucci\JWT\Encoding\TimestampDates;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use DateTimeImmutable; use DateTimeImmutable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\Node; use App\Models\Node;
use App\Models\User; use App\Models\User;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Key\InMemory;
use App\Extensions\Lcobucci\JWT\Encoding\TimestampDates; use Lcobucci\JWT\UnencryptedToken;
class NodeJWTService class NodeJWTService
{ {
@ -64,7 +64,7 @@ class NodeJWTService
/** /**
* Generate a new JWT for a given node. * Generate a new JWT for a given node.
*/ */
public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'): Plain public function handle(Node $node, ?string $identifiedBy, string $algo = 'sha256'): UnencryptedToken
{ {
$identifier = hash($algo, $identifiedBy); $identifier = hash($algo, $identifiedBy);
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->daemon_token)); $config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->daemon_token));
@ -80,7 +80,9 @@ class NodeJWTService
$builder = $builder->expiresAt($this->expiresAt); $builder = $builder->expiresAt($this->expiresAt);
if (!empty($this->subject)) { if (!empty($this->subject)) {
$builder = $builder->relatedTo($this->subject)->withHeader('sub', $this->subject); $builder = $builder
->relatedTo($this->subject)
->withHeader('sub', $this->subject);
} }
foreach ($this->claims as $key => $value) { foreach ($this->claims as $key => $value) {
@ -88,14 +90,7 @@ class NodeJWTService
} }
if (!is_null($this->user)) { if (!is_null($this->user)) {
$builder = $builder $builder = $builder->withClaim('user_uuid', $this->user->uuid);
->withClaim('user_uuid', $this->user->uuid)
// The "user_id" claim is deprecated and should not be referenced — it remains
// here solely to ensure older versions of daemon are unaffected when the Panel
// is updated.
//
// This claim will be removed in Panel@1.11 or later.
->withClaim('user_id', $this->user->id);
} }
return $builder return $builder

View File

@ -39,15 +39,7 @@ class ServerCreationService
* as possible given the input data. For example, if an allocation_id is passed with * as possible given the input data. For example, if an allocation_id is passed with
* no node_id the node_is will be picked from the allocation. * no node_id the node_is will be picked from the allocation.
* *
* @param array{ * @param array<mixed, mixed> $data
* node_id?: int,
* oom_killer?: bool,
* oom_disabled?: bool,
* egg_id?: int,
* image?: ?string,
* startup?: ?string,
* start_on_completion?: ?bool,
* } $data
* *
* @throws \Throwable * @throws \Throwable
* @throws \App\Exceptions\DisplayException * @throws \App\Exceptions\DisplayException
@ -64,8 +56,8 @@ class ServerCreationService
$egg = Egg::query()->findOrFail($data['egg_id']); $egg = Egg::query()->findOrFail($data['egg_id']);
// Fill missing fields from egg // Fill missing fields from egg
$data['image'] = $data['image'] ?? collect($egg->docker_images)->first(); $data['image'] ??= collect($egg->docker_images)->first();
$data['startup'] = $data['startup'] ?? $egg->startup; $data['startup'] ??= $egg->startup;
// If a deployment object has been passed we need to get the allocation and node that the server should use. // If a deployment object has been passed we need to get the allocation and node that the server should use.
if ($deployment) { if ($deployment) {
@ -94,6 +86,8 @@ class ServerCreationService
if (empty($data['node_id'])) { if (empty($data['node_id'])) {
$data['node_id'] = $nodes->first(); $data['node_id'] = $nodes->first();
} }
} else {
$data['node_id'] ??= Allocation::find($data['allocation_id'])?->node_id;
} }
Assert::false(empty($data['node_id']), 'Expected a non-empty node_id in server creation data.'); Assert::false(empty($data['node_id']), 'Expected a non-empty node_id in server creation data.');

View File

@ -10,7 +10,7 @@ use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\UnencryptedToken;
class TransferServerService class TransferServerService
{ {
@ -22,7 +22,7 @@ class TransferServerService
private NodeJWTService $nodeJWTService, private NodeJWTService $nodeJWTService,
) {} ) {}
private function notify(ServerTransfer $transfer, Plain $token): void private function notify(ServerTransfer $transfer, UnencryptedToken $token): void
{ {
Http::daemon($transfer->oldNode)->post("/api/servers/{$transfer->server->uuid}/transfer", [ Http::daemon($transfer->oldNode)->post("/api/servers/{$transfer->server->uuid}/transfer", [
'url' => $transfer->newNode->getConnectionAddress() . '/api/transfers', 'url' => $transfer->newNode->getConnectionAddress() . '/api/transfers',

View File

@ -4,7 +4,6 @@ namespace App\Services\Subusers;
use App\Events\Server\SubUserAdded; use App\Events\Server\SubUserAdded;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Str;
use App\Models\Server; use App\Models\Server;
use App\Models\Subuser; use App\Models\Subuser;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
@ -40,14 +39,8 @@ class SubuserCreationService
return $this->connection->transaction(function () use ($server, $email, $permissions) { return $this->connection->transaction(function () use ($server, $email, $permissions) {
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if (!$user) { if (!$user) {
// Just cap the username generated at 64 characters at most and then append a random string
// to the end to make it "unique"...
[$beforeDomain] = explode('@', $email, 1);
$username = substr(preg_replace('/([^\w.-]+)/', '', $beforeDomain), 0, 64) . Str::random(3);
$user = $this->userCreationService->handle([ $user = $this->userCreationService->handle([
'email' => $email, 'email' => $email,
'username' => $username,
'root_admin' => false, 'root_admin' => false,
]); ]);
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\Users; namespace App\Services\Users;
use App\Models\Role; use App\Models\Role;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Hashing\Hasher;
@ -42,6 +43,16 @@ class UserCreationService
$isRootAdmin = array_key_exists('root_admin', $data) && $data['root_admin']; $isRootAdmin = array_key_exists('root_admin', $data) && $data['root_admin'];
unset($data['root_admin']); unset($data['root_admin']);
if (empty($data['username'])) {
$data['username'] = str($data['email'])->before('@')->toString() . Str::random(3);
}
$data['username'] = str($data['username'])
->replace(['.', '-'], '')
->ascii()
->substr(0, 64)
->toString();
/** @var User $user */ /** @var User $user */
$user = User::query()->forceCreate(array_merge($data, [ $user = User::query()->forceCreate(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(), 'uuid' => Uuid::uuid4()->toString(),

View File

@ -45,6 +45,7 @@ services:
<<: [*panel-environment, *mail-environment] <<: [*panel-environment, *mail-environment]
XDG_DATA_HOME: /pelican-data XDG_DATA_HOME: /pelican-data
# SKIP_CADDY: true # enable when not using caddy. # SKIP_CADDY: true # enable when not using caddy.
TRUSTED_PROXIES:
volumes: volumes:
pelican-data: pelican-data:

View File

@ -17,13 +17,13 @@
"doctrine/dbal": "~3.6.0", "doctrine/dbal": "~3.6.0",
"filament/filament": "^3.3", "filament/filament": "^3.3",
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"laravel/framework": "^12.21", "laravel/framework": "^12.23",
"laravel/helpers": "^1.7", "laravel/helpers": "^1.7",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.21", "laravel/socialite": "^5.21",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6", "laravel/ui": "^4.6",
"lcobucci/jwt": "~4.3.0", "lcobucci/jwt": "^5.5",
"league/flysystem-aws-s3-v3": "^3.29", "league/flysystem-aws-s3-v3": "^3.29",
"league/flysystem-memory": "^3.29", "league/flysystem-memory": "^3.29",
"phpseclib/phpseclib": "~3.0.18", "phpseclib/phpseclib": "~3.0.18",
@ -77,18 +77,13 @@
} }
}, },
"scripts": { "scripts": {
"cs:fix": "php-cs-fixer fix", "pint": "pint",
"cs:check": "php-cs-fixer fix --dry-run --diff --verbose",
"phpstan": "phpstan --memory-limit=-1", "phpstan": "phpstan --memory-limit=-1",
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump" "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump"
], ],
"post-install-cmd": [ "post-install-cmd": [
"php -r \"file_exists('.env') || copy('.env.example', '.env');\"" "php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
] ]
}, },
"config": { "config": {

823
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,6 @@ return [
], ],
'filament' => [ 'filament' => [
'top-navigation' => env('FILAMENT_TOP_NAVIGATION', false),
'display-width' => env('FILAMENT_WIDTH', 'screen-2xl'), 'display-width' => env('FILAMENT_WIDTH', 'screen-2xl'),
'avatar-provider' => env('FILAMENT_AVATAR_PROVIDER', 'gravatar'), 'avatar-provider' => env('FILAMENT_AVATAR_PROVIDER', 'gravatar'),
'uploadable-avatars' => env('FILAMENT_UPLOADABLE_AVATARS', false), 'uploadable-avatars' => env('FILAMENT_UPLOADABLE_AVATARS', false),

View File

@ -3,10 +3,12 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Egg; use App\Models\Egg;
use Exception; use DirectoryIterator;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use App\Services\Eggs\Sharing\EggImporterService; use App\Services\Eggs\Sharing\EggImporterService;
use Symfony\Component\Yaml\Yaml;
use Throwable;
class EggSeeder extends Seeder class EggSeeder extends Seeder
{ {
@ -46,22 +48,39 @@ class EggSeeder extends Seeder
*/ */
protected function parseEggFiles($name): void protected function parseEggFiles($name): void
{ {
$files = new \DirectoryIterator(database_path('Seeders/eggs/' . kebab_case($name))); $path = database_path('Seeders/eggs/' . kebab_case($name));
$files = new DirectoryIterator($path);
$this->command->alert('Updating Eggs for: ' . $name); $this->command->alert('Updating Eggs for: ' . $name);
/** @var \DirectoryIterator $file */
/** @var DirectoryIterator $file */
foreach ($files as $file) { foreach ($files as $file) {
if (!$file->isFile() || !$file->isReadable()) { if (!$file->isFile() || !$file->isReadable()) {
continue; continue;
} }
$extension = strtolower($file->getExtension());
$filePath = $file->getRealPath();
try { try {
$decoded = json_decode(file_get_contents($file->getRealPath()), true, 512, JSON_THROW_ON_ERROR); $decoded = match ($extension) {
} catch (Exception) { 'json' => json_decode(file_get_contents($filePath), true, 512, JSON_THROW_ON_ERROR),
'yaml', 'yml' => Yaml::parseFile($filePath),
default => null,
};
} catch (Throwable) {
$this->command->warn("Failed to parse {$file->getFilename()}, skipping.");
continue; continue;
} }
$file = new UploadedFile($file->getPathname(), $file->getFilename(), 'application/json'); if (!is_array($decoded) || !isset($decoded['name'], $decoded['author'])) {
$this->command->warn("Invalid structure in {$file->getFilename()}, skipping.");
continue;
}
$uploaded = new UploadedFile($filePath, $file->getFilename());
$egg = Egg::query() $egg = Egg::query()
->where('author', $decoded['author']) ->where('author', $decoded['author'])
@ -69,10 +88,10 @@ class EggSeeder extends Seeder
->first(); ->first();
if ($egg instanceof Egg) { if ($egg instanceof Egg) {
$this->importerService->fromFile($file, $egg); $this->importerService->fromFile($uploaded, $egg);
$this->command->info('Updated ' . $decoded['name']); $this->command->info('Updated ' . $decoded['name']);
} else { } else {
$this->importerService->fromFile($file); $this->importerService->fromFile($uploaded);
$this->command->comment('Created ' . $decoded['name']); $this->command->comment('Created ' . $decoded['name']);
} }
} }

View File

@ -1,72 +0,0 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL",
"meta": {
"version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/minecraft\/egg-bungeecord.json"
},
"exported_at": "2025-03-18T12:35:34+00:00",
"name": "Bungeecord",
"author": "panel@example.com",
"uuid": "9e6b409e-4028-4947-aea8-50a2c404c271",
"description": "For a long time, Minecraft server owners have had a dream that encompasses a free, easy, and reliable way to connect multiple Minecraft servers together. BungeeCord is the answer to said dream. Whether you are a small server wishing to string multiple game-modes together, or the owner of the ShotBow Network, BungeeCord is the ideal solution for you. With the help of BungeeCord, you will be able to unlock your community's full potential.",
"tags": [
"minecraft",
"proxy"
],
"features": [
"eula",
"java_version",
"pid_limit"
],
"docker_images": {
"Java 21": "ghcr.io\/parkervcp\/yolks:java_21",
"Java 17": "ghcr.io\/parkervcp\/yolks:java_17",
"Java 16": "ghcr.io\/parkervcp\/yolks:java_16",
"Java 11": "ghcr.io\/parkervcp\/yolks:java_11",
"Java 8": "ghcr.io\/parkervcp\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}",
"config": {
"files": "{\r\n \"config.yml\": {\r\n \"parser\": \"yaml\",\r\n \"find\": {\r\n \"listeners[0].query_port\": \"{{server.allocations.default.port}}\",\r\n \"listeners[0].host\": \"0.0.0.0:{{server.allocations.default.port}}\",\r\n \"servers.*.address\": {\r\n \"regex:^(127\\\\.0\\\\.0\\\\.1|localhost)(:\\\\d{1,5})?$\": \"{{config.docker.interface}}$2\"\r\n }\r\n }\r\n }\r\n}",
"startup": "{\r\n \"done\": \"Listening on \"\r\n}",
"logs": "{}",
"stop": "end"
},
"scripts": {
"installation": {
"script": "#!\/bin\/ash\r\n# Bungeecord Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\ncd \/mnt\/server\r\n\r\nif [ -z \"${BUNGEE_VERSION}\" ] || [ \"${BUNGEE_VERSION}\" == \"latest\" ]; then\r\n BUNGEE_VERSION=\"lastStableBuild\"\r\nfi\r\n\r\ncurl -o ${SERVER_JARFILE} https:\/\/ci.md-5.net\/job\/BungeeCord\/${BUNGEE_VERSION}\/artifact\/bootstrap\/target\/BungeeCord.jar",
"container": "ghcr.io\/parkervcp\/installers:alpine",
"entrypoint": "ash"
}
},
"variables": [
{
"name": "Bungeecord Version",
"description": "The version of Bungeecord to download and use.",
"env_variable": "BUNGEE_VERSION",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"alpha_num",
"between:1,6"
],
"sort": 1
},
{
"name": "Bungeecord Jar File",
"description": "The name of the Jarfile to use when running Bungeecord.",
"env_variable": "SERVER_JARFILE",
"default_value": "bungeecord.jar",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
],
"sort": 2
}
]
}

View File

@ -0,0 +1,83 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v2
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-bungeecord.yaml'
exported_at: '2025-07-25T13:32:34+00:00'
name: Bungeecord
author: panel@example.com
uuid: 9e6b409e-4028-4947-aea8-50a2c404c271
description: |-
For a long time, Minecraft server owners have had a dream that encompasses a free, easy, and
reliable way to connect multiple Minecraft servers together. BungeeCord is the answer to said dream.
Whether you are a small server wishing to string multiple game-modes together, or the owner of the
ShotBow Network, BungeeCord is the ideal solution for you. With the help of BungeeCord, you will be
able to unlock your community's full potential.
tags:
- minecraft
- proxy
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/parkervcp/yolks:java_21'
'Java 17': 'ghcr.io/parkervcp/yolks:java_17'
'Java 16': 'ghcr.io/parkervcp/yolks:java_16'
'Java 11': 'ghcr.io/parkervcp/yolks:java_11'
'Java 8': 'ghcr.io/parkervcp/yolks:java_8'
file_denylist: { }
startup: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}'
config:
files:
config.yml:
parser: yaml
find:
'listeners[0].query_port': '{{server.allocations.default.port}}'
'listeners[0].host': '0.0.0.0:{{server.allocations.default.port}}'
'servers.*.address':
'regex:^(127\.0\.0\.1|localhost)(:\d{1,5})?$': '{{config.docker.interface}}$2'
startup:
done: 'Listening on '
logs: { }
stop: end
scripts:
installation:
script: |-
#!/bin/ash
# Bungeecord Installation Script
#
# Server Files: /mnt/server
cd /mnt/server
if [ -z "${BUNGEE_VERSION}" ] || [ "${BUNGEE_VERSION}" == "latest" ]; then
BUNGEE_VERSION="lastStableBuild"
fi
curl -o ${SERVER_JARFILE} https://ci.md-5.net/job/BungeeCord/${BUNGEE_VERSION}/artifact/bootstrap/target/BungeeCord.jar
container: 'ghcr.io/parkervcp/installers:alpine'
entrypoint: ash
variables:
-
name: 'Bungeecord Jar File'
description: 'The name of the Jarfile to use when running Bungeecord.'
env_variable: SERVER_JARFILE
default_value: bungeecord.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 2
-
name: 'Bungeecord Version'
description: 'The version of Bungeecord to download and use.'
env_variable: BUNGEE_VERSION
default_value: latest
user_viewable: true
user_editable: true
rules:
- required
- alpha_num
- 'between:1,6'
sort: 1

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,217 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v2
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-forge-minecraft.yaml'
exported_at: '2025-08-05T21:00:17+00:00'
name: 'Forge Minecraft'
author: panel@example.com
uuid: ed072427-f209-4603-875c-f540c6dd5a65
description: |-
Minecraft Forge Server. Minecraft Forge is a modding API (Application Programming Interface), which
makes it easier to create mods, and also make sure mods are compatible with each other.
tags:
- minecraft
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/parkervcp/yolks:java_21'
'Java 17': 'ghcr.io/parkervcp/yolks:java_17'
'Java 16': 'ghcr.io/parkervcp/yolks:java_16'
'Java 11': 'ghcr.io/parkervcp/yolks:java_11'
'Java 8': 'ghcr.io/parkervcp/yolks:java_8'
file_denylist: { }
startup: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true $( [[ ! -f unix_args.txt ]] && printf %s "-jar {{SERVER_JARFILE}}" || printf %s "@unix_args.txt" )'
config:
files:
server.properties:
parser: properties
find:
server-ip: ''
server-port: '{{server.allocations.default.port}}'
query.port: '{{server.allocations.default.port}}'
startup:
done: ')! For help, type '
logs: { }
stop: stop
scripts:
installation:
script: |-
#!/bin/bash
# Forge Installation Script
#
# Server Files: /mnt/server
apt update
apt install -y curl jq
if [[ ! -d /mnt/server ]]; then
mkdir /mnt/server
fi
cd /mnt/server
# Remove spaces from the version number to avoid issues with curl
FORGE_VERSION="$(echo "$FORGE_VERSION" | tr -d ' ')"
MC_VERSION="$(echo "$MC_VERSION" | tr -d ' ')"
if [[ ! -z ${FORGE_VERSION} ]]; then
DOWNLOAD_LINK=https://maven.minecraftforge.net/net/minecraftforge/forge/${FORGE_VERSION}/forge-${FORGE_VERSION}
FORGE_JAR=forge-${FORGE_VERSION}*.jar
else
JSON_DATA=$(curl -sSL https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json)
if [[ "${MC_VERSION}" == "latest" ]] || [[ "${MC_VERSION}" == "" ]]; then
echo -e "getting latest version of forge."
MC_VERSION=$(echo -e ${JSON_DATA} | jq -r '.promos | del(."latest-1.7.10") | del(."1.7.10-latest-1.7.10") | to_entries[] | .key | select(contains("latest")) | split("-")[0]' | sort -t. -k 1,1n -k 2,2n -k 3,3n -k 4,4n | tail -1)
BUILD_TYPE=latest
fi
if [[ "${BUILD_TYPE}" != "recommended" ]] && [[ "${BUILD_TYPE}" != "latest" ]]; then
BUILD_TYPE=recommended
fi
echo -e "minecraft version: ${MC_VERSION}"
echo -e "build type: ${BUILD_TYPE}"
## some variables for getting versions and things
FILE_SITE=https://maven.minecraftforge.net/net/minecraftforge/forge/
VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION "${MC_VERSION}" --arg BUILD_TYPE "${BUILD_TYPE}" '.promos | del(."latest-1.7.10") | del(."1.7.10-latest-1.7.10") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains($BUILD_TYPE))')
## locating the forge version
if [[ "${VERSION_KEY}" == "" ]] && [[ "${BUILD_TYPE}" == "recommended" ]]; then
echo -e "dropping back to latest from recommended due to there not being a recommended version of forge for the mc version requested."
VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION "${MC_VERSION}" '.promos | del(."latest-1.7.10") | del(."1.7.10-latest-1.7.10") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains("latest"))')
fi
## Error if the mc version set wasn't valid.
if [ "${VERSION_KEY}" == "" ] || [ "${VERSION_KEY}" == "null" ]; then
echo -e "The install failed because there is no valid version of forge for the version of minecraft selected."
exit 1
fi
FORGE_VERSION=$(echo -e ${JSON_DATA} | jq -r --arg VERSION_KEY "$VERSION_KEY" '.promos | .[$VERSION_KEY]')
if [[ "${MC_VERSION}" == "1.7.10" ]] || [[ "${MC_VERSION}" == "1.8.9" ]]; then
DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}/forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}
FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}.jar
if [[ "${MC_VERSION}" == "1.7.10" ]]; then
FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}-universal.jar
fi
else
DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}/forge-${MC_VERSION}-${FORGE_VERSION}
FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}.jar
fi
fi
#Adding .jar when not eding by SERVER_JARFILE
if [[ ! $SERVER_JARFILE = *\.jar ]]; then
SERVER_JARFILE="$SERVER_JARFILE.jar"
fi
#Downloading jars
echo -e "Downloading forge version ${FORGE_VERSION}"
echo -e "Download link is ${DOWNLOAD_LINK}"
if [[ ! -z "${DOWNLOAD_LINK}" ]]; then
if curl --output /dev/null --silent --head --fail ${DOWNLOAD_LINK}-installer.jar; then
echo -e "installer jar download link is valid."
else
echo -e "link is invalid. Exiting now"
exit 2
fi
else
echo -e "no download link provided. Exiting now"
exit 3
fi
curl -s -o installer.jar -sS ${DOWNLOAD_LINK}-installer.jar
#Checking if downloaded jars exist
if [[ ! -f ./installer.jar ]]; then
echo "!!! Error downloading forge version ${FORGE_VERSION} !!!"
exit
fi
function unix_args {
echo -e "Detected Forge 1.17 or newer version. Setting up forge unix args."
ln -sf libraries/net/minecraftforge/forge/*/unix_args.txt unix_args.txt
}
# Delete args to support downgrading/upgrading
rm -rf libraries/net/minecraftforge/forge
rm unix_args.txt
#Installing server
echo -e "Installing forge server.
"
java -jar installer.jar --installServer || { echo -e "
Install failed using Forge version ${FORGE_VERSION} and Minecraft version ${MINECRAFT_VERSION}.
Should you be using unlimited memory value of 0, make sure to increase the default install resource limits in the Daemon config or specify exact allocated memory in the server Build Configuration instead of 0!
Otherwise, the Forge installer will not have enough memory."; exit 4; }
# Check if we need a symlink for 1.17+ Forge JPMS args
if [[ $MC_VERSION =~ ^1\.(17|18|19|20|21|22|23) || $FORGE_VERSION =~ ^1\.(17|18|19|20|21|22|23) ]]; then
unix_args
# Check if someone has set MC to latest but overwrote it with older Forge version, otherwise we would have false positives
elif [[ $MC_VERSION == "latest" && $FORGE_VERSION =~ ^1\.(17|18|19|20|21|22|23) ]]; then
unix_args
else
# For versions below 1.17 that ship with jar
mv $FORGE_JAR $SERVER_JARFILE
fi
echo -e "Deleting installer.jar file.
"
rm -rf installer.jar
echo -e "Installation process is completed"
container: 'openjdk:8-jdk-slim'
entrypoint: bash
variables:
-
name: 'Build Type'
description: "The type of server jar to download from forge.\r\n\r\nValid types are \"recommended\" and \"latest\"."
env_variable: BUILD_TYPE
default_value: recommended
user_viewable: true
user_editable: true
rules:
- required
- string
- 'in:recommended,latest'
sort: 3
-
name: 'Forge Version'
description: "The full exact version.\r\n\r\nEx. 1.15.2-31.2.4\r\n\r\nOverrides MC_VERSION and BUILD_TYPE. If it fails to download the server files it will fail to\ninstall."
env_variable: FORGE_VERSION
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- 'regex:/^[0-9\.\-]+$/'
sort: 4
-
name: 'Minecraft Version'
description: "The version of minecraft you want to install for.\r\n\r\nLeaving latest will install the latest recommended version."
env_variable: MC_VERSION
default_value: latest
user_viewable: true
user_editable: true
rules:
- required
- string
- 'max:9'
sort: 2
-
name: 'Server Jar File'
description: 'The name of the Jarfile to use when running Forge version below 1.17.'
env_variable: SERVER_JARFILE
default_value: server.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 1

View File

@ -1,98 +0,0 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL",
"meta": {
"version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/minecraft\/egg-paper.json"
},
"exported_at": "2025-03-18T12:35:44+00:00",
"name": "Paper",
"author": "parker@example.com",
"uuid": "5da37ef6-58da-4169-90a6-e683e1721247",
"description": "High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.",
"tags": [
"minecraft"
],
"features": [
"eula",
"java_version",
"pid_limit"
],
"docker_images": {
"Java 21": "ghcr.io\/parkervcp\/yolks:java_21",
"Java 17": "ghcr.io\/parkervcp\/yolks:java_17",
"Java 16": "ghcr.io\/parkervcp\/yolks:java_16",
"Java 11": "ghcr.io\/parkervcp\/yolks:java_11",
"Java 8": "ghcr.io\/parkervcp\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}",
"config": {
"files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.allocations.default.port}}\",\r\n \"query.port\": \"{{server.allocations.default.port}}\"\r\n }\r\n }\r\n}",
"startup": "{\r\n \"done\": \")! For help, type \"\r\n}",
"logs": "{}",
"stop": "stop"
},
"scripts": {
"installation": {
"script": "#!\/bin\/ash\r\n# Paper Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\nPROJECT=paper\r\n\r\nif [ -n \"${DL_PATH}\" ]; then\r\n\techo -e \"Using supplied download url: ${DL_PATH}\"\r\n\tDOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's\/{{\/${\/g' -e 's\/}}\/}\/g')`\r\nelse\r\n\tVER_EXISTS=`curl -s https:\/\/api.papermc.io\/v2\/projects\/${PROJECT} | jq -r --arg VERSION $MINECRAFT_VERSION '.versions[] | contains($VERSION)' | grep -m1 true`\r\n\tLATEST_VERSION=`curl -s https:\/\/api.papermc.io\/v2\/projects\/${PROJECT} | jq -r '.versions' | jq -r '.[-1]'`\r\n\r\n\tif [ \"${VER_EXISTS}\" == \"true\" ]; then\r\n\t\techo -e \"Version is valid. Using version ${MINECRAFT_VERSION}\"\r\n\telse\r\n\t\techo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\r\n\t\tMINECRAFT_VERSION=${LATEST_VERSION}\r\n\tfi\r\n\r\n\tBUILD_EXISTS=`curl -s https:\/\/api.papermc.io\/v2\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\r\n\tLATEST_BUILD=`curl -s https:\/\/api.papermc.io\/v2\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r '.builds' | jq -r '.[-1]'`\r\n\r\n\tif [ \"${BUILD_EXISTS}\" == \"true\" ]; then\r\n\t\techo -e \"Build is valid for version ${MINECRAFT_VERSION}. Using build ${BUILD_NUMBER}\"\r\n\telse\r\n\t\techo -e \"Using the latest ${PROJECT} build for version ${MINECRAFT_VERSION}\"\r\n\t\tBUILD_NUMBER=${LATEST_BUILD}\r\n\tfi\r\n\r\n\tJAR_NAME=${PROJECT}-${MINECRAFT_VERSION}-${BUILD_NUMBER}.jar\r\n\r\n\techo \"Version being downloaded\"\r\n\techo -e \"MC Version: ${MINECRAFT_VERSION}\"\r\n\techo -e \"Build: ${BUILD_NUMBER}\"\r\n\techo -e \"JAR Name of Build: ${JAR_NAME}\"\r\n\tDOWNLOAD_URL=https:\/\/api.papermc.io\/v2\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION}\/builds\/${BUILD_NUMBER}\/downloads\/${JAR_NAME}\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\r\n\r\nif [ -f ${SERVER_JARFILE} ]; then\r\n\tmv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\r\nfi\r\n\r\ncurl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\r\n\r\nif [ ! -f server.properties ]; then\r\n echo -e \"Downloading MC server.properties\"\r\n curl -o server.properties https:\/\/raw.githubusercontent.com\/parkervcp\/eggs\/master\/minecraft\/java\/server.properties\r\nfi",
"container": "ghcr.io\/parkervcp\/installers:alpine",
"entrypoint": "ash"
}
},
"variables": [
{
"name": "Minecraft Version",
"description": "The version of minecraft to download. \r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.",
"env_variable": "MINECRAFT_VERSION",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"nullable",
"string",
"max:20"
],
"sort": 1
},
{
"name": "Server Jar File",
"description": "The name of the server jarfile to run the server with.",
"env_variable": "SERVER_JARFILE",
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
],
"sort": 2
},
{
"name": "Download Path",
"description": "A URL to use to download a server.jar rather than the ones in the install script. This is not user viewable.",
"env_variable": "DL_PATH",
"default_value": "",
"user_viewable": false,
"user_editable": false,
"rules": [
"nullable",
"string"
],
"sort": 3
},
{
"name": "Build Number",
"description": "The build number for the paper release.\r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.",
"env_variable": "BUILD_NUMBER",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"string",
"max:20"
],
"sort": 4
}
]
}

View File

@ -0,0 +1,142 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v2
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-paper.yaml'
exported_at: '2025-08-05T21:00:17+00:00'
name: Paper
author: parker@example.com
uuid: 5da37ef6-58da-4169-90a6-e683e1721247
description: 'High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.'
tags:
- minecraft
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/parkervcp/yolks:java_21'
'Java 17': 'ghcr.io/parkervcp/yolks:java_17'
'Java 16': 'ghcr.io/parkervcp/yolks:java_16'
'Java 11': 'ghcr.io/parkervcp/yolks:java_11'
'Java 8': 'ghcr.io/parkervcp/yolks:java_8'
file_denylist: { }
startup: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}'
config:
files:
server.properties:
parser: properties
find:
server-ip: ''
server-port: '{{server.allocations.default.port}}'
query.port: '{{server.allocations.default.port}}'
startup:
done: ')! For help, type '
logs: { }
stop: stop
scripts:
installation:
script: |-
#!/bin/ash
# Paper Installation Script
#
# Server Files: /mnt/server
PROJECT=paper
if [ -n "${DL_PATH}" ]; then
echo -e "Using supplied download url: ${DL_PATH}"
DOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')`
else
VER_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r --arg VERSION $MINECRAFT_VERSION '.versions[] | contains($VERSION)' | grep -m1 true`
LATEST_VERSION=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r '.versions' | jq -r '.[-1]'`
if [ "${VER_EXISTS}" == "true" ]; then
echo -e "Version is valid. Using version ${MINECRAFT_VERSION}"
else
echo -e "Specified version not found. Defaulting to the latest ${PROJECT} version"
MINECRAFT_VERSION=${LATEST_VERSION}
fi
BUILD_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`
LATEST_BUILD=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION} | jq -r '.builds' | jq -r '.[-1]'`
if [ "${BUILD_EXISTS}" == "true" ]; then
echo -e "Build is valid for version ${MINECRAFT_VERSION}. Using build ${BUILD_NUMBER}"
else
echo -e "Using the latest ${PROJECT} build for version ${MINECRAFT_VERSION}"
BUILD_NUMBER=${LATEST_BUILD}
fi
JAR_NAME=${PROJECT}-${MINECRAFT_VERSION}-${BUILD_NUMBER}.jar
echo "Version being downloaded"
echo -e "MC Version: ${MINECRAFT_VERSION}"
echo -e "Build: ${BUILD_NUMBER}"
echo -e "JAR Name of Build: ${JAR_NAME}"
DOWNLOAD_URL=https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION}/builds/${BUILD_NUMBER}/downloads/${JAR_NAME}
fi
cd /mnt/server
echo -e "Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}"
if [ -f ${SERVER_JARFILE} ]; then
mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old
fi
curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}
if [ ! -f server.properties ]; then
echo -e "Downloading MC server.properties"
curl -o server.properties https://raw.githubusercontent.com/parkervcp/eggs/master/minecraft/java/server.properties
fi
container: 'ghcr.io/parkervcp/installers:alpine'
entrypoint: ash
variables:
-
name: 'Build Number'
description: "The build number for the paper release.\r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest."
env_variable: BUILD_NUMBER
default_value: latest
user_viewable: true
user_editable: true
rules:
- required
- string
- 'max:20'
sort: 4
-
name: 'Download Path'
description: |-
A URL to use to download a server.jar rather than the ones in the install script. This is not user
viewable.
env_variable: DL_PATH
default_value: ''
user_viewable: false
user_editable: false
rules:
- nullable
- string
sort: 3
-
name: 'Minecraft Version'
description: "The version of minecraft to download. \r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest."
env_variable: MINECRAFT_VERSION
default_value: latest
user_viewable: true
user_editable: true
rules:
- nullable
- string
- 'max:20'
sort: 1
-
name: 'Server Jar File'
description: 'The name of the server jarfile to run the server with.'
env_variable: SERVER_JARFILE
default_value: server.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 2

View File

@ -1,96 +0,0 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL",
"meta": {
"version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/minecraft\/egg-sponge--sponge-vanilla.json"
},
"exported_at": "2025-04-25T06:05:10+00:00",
"name": "Sponge",
"author": "panel@example.com",
"uuid": "f0d2f88f-1ff3-42a0-b03f-ac44c5571e6d",
"description": "A community-driven open source Minecraft: Java Edition modding platform.",
"tags": [
"minecraft"
],
"features": [
"eula",
"java_version",
"pid_limit"
],
"docker_images": {
"Java 21": "ghcr.io\/parkervcp\/yolks:java_21",
"Java 17": "ghcr.io\/parkervcp\/yolks:java_17",
"Java 16": "ghcr.io\/parkervcp\/yolks:java_16",
"Java 11": "ghcr.io\/parkervcp\/yolks:java_11",
"Java 8": "ghcr.io\/parkervcp\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}",
"config": {
"files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.allocations.default.port}}\",\r\n \"query.port\": \"{{server.allocations.default.port}}\"\r\n }\r\n }\r\n}",
"startup": "{\r\n \"done\": \")! For help, type \"\r\n}",
"logs": "{}",
"stop": "stop"
},
"scripts": {
"installation": {
"script": "#!\/bin\/ash\r\n# Sponge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\ncd \/mnt\/server\r\n\r\nif [ $MINECRAFT_VERSION = 'latest' ] || [ -z $MINECRAFT_VERSION ]; then\r\n TARGET_VERSION_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/latest?recommended=true)\r\n if [ -z \"${TARGET_VERSION_JSON}\" ]; then\r\n echo -e \"Failed to find latest recommended version!\"\r\n exit 1\r\n fi\r\n echo -e \"Found latest version for ${SPONGE_TYPE}\"\r\nelse\r\n if [ $SPONGE_TYPE = 'spongevanilla' ]; then \r\n VERSIONS_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions?tags=,minecraft:${MINECRAFT_VERSION}&offset=0&limit=1)\r\n else\r\n FORGETAG='forge'\r\n if [ $SPONGE_TYPE = 'spongeneo' ]; then\r\n FORGETAG='neoforge'\r\n fi\r\n VERSIONS_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions?tags=,minecraft:${MINECRAFT_VERSION},${FORGETAG}:${FORGE_VERSION}&offset=0&limit=1)\r\n fi\r\n \r\n if [ -z \"${VERSIONS_JSON}\" ]; then\r\n echo -e \"Failed to find recommended ${MINECRAFT_VERSION} version for ${SPONGE_TYPE} ${FORGE_VERSION}!\"\r\n exit 1\r\n fi\r\n \r\n VERSION_KEY=$(echo $VERSIONS_JSON | jq -r '.artifacts | to_entries[0].key')\r\n TARGET_VERSION_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions\/${VERSION_KEY})\r\n \r\n if [ -z \"${TARGET_VERSION_JSON}\" ]; then\r\n echo -e \"Failed to find ${VERSION_KEY} for ${SPONGE_TYPE} ${FORGE_VERSION}!\"\r\n exit 1\r\n fi\r\n\r\n echo -e \"Found ${MINECRAFT_VERSION} for ${SPONGE_TYPE}\"\r\nfi\r\n\r\nTARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == \"universal\")'`\r\nif [ -z \"${TARGET_VERSION}\" ]; then\r\n TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == \"\" and .extension == \"jar\")'`\r\nfi\r\n\r\nif [ -z \"${TARGET_VERSION}\" ]; then\r\n echo -e \"Failed to get download url data from the selected version\"\r\n exit 1\r\nfi\r\n\r\nSPONGE_URL=$(echo $TARGET_VERSION | jq -r '.downloadUrl')\r\nCHECKSUM=$(echo $TARGET_VERSION | jq -r '.md5')\r\necho -e \"Found file at ${SPONGE_URL} with checksum ${CHECKSUM}\"\r\n\r\necho -e \"running: curl -o ${SERVER_JARFILE} ${SPONGE_URL}\"\r\ncurl -o ${SERVER_JARFILE} ${SPONGE_URL}\r\n\r\nif [ $(basename $(md5sum ${SERVER_JARFILE})) = ${CHECKSUM} ] ; then\r\n echo \"Checksum passed\"\r\nelse\r\n echo \"Checksum failed\"\r\nfi\r\n\r\necho -e \"Install Complete\"",
"container": "ghcr.io\/parkervcp\/installers:alpine",
"entrypoint": "ash"
}
},
"variables": [
{
"sort": 3,
"name": "Forge\/Neoforge Version",
"description": "The modding api target version if set to `spongeforge` or `spongeneo`. Leave blank if using `spongevanilla`",
"env_variable": "FORGE_VERSION",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": [
"string"
]
},
{
"sort": 1,
"name": "Minecraft Version",
"description": "The version of Minecraft to target. Use \"latest\" to install the latest version. Go to Settings > Reinstall Server to apply.",
"env_variable": "MINECRAFT_VERSION",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"string",
"between:3,15"
]
},
{
"sort": 4,
"name": "Server Jar File",
"description": "The name of the Jarfile to use when running Sponge.",
"env_variable": "SERVER_JARFILE",
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
]
},
{
"sort": 2,
"name": "Sponge Type",
"description": "SpongeVanilla if you are only using Sponge plugins.\nSpongeForge when using Forge mods and Sponge plugins.\nSpongeNeo when using NeoForge mods and Sponge plugins.",
"env_variable": "SPONGE_TYPE",
"default_value": "spongevanilla",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"in:spongevanilla,spongeforge,spongeneo"
]
}
]
}

View File

@ -0,0 +1,157 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v2
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-sponge--sponge-vanilla.yaml'
exported_at: '2025-08-05T21:00:17+00:00'
name: Sponge
author: panel@example.com
uuid: f0d2f88f-1ff3-42a0-b03f-ac44c5571e6d
description: 'A community-driven open source Minecraft: Java Edition modding platform.'
tags:
- minecraft
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/parkervcp/yolks:java_21'
'Java 17': 'ghcr.io/parkervcp/yolks:java_17'
'Java 16': 'ghcr.io/parkervcp/yolks:java_16'
'Java 11': 'ghcr.io/parkervcp/yolks:java_11'
'Java 8': 'ghcr.io/parkervcp/yolks:java_8'
file_denylist: { }
startup: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}'
config:
files:
server.properties:
parser: properties
find:
server-ip: ''
server-port: '{{server.allocations.default.port}}'
query.port: '{{server.allocations.default.port}}'
startup:
done: ')! For help, type '
logs: { }
stop: stop
scripts:
installation:
script: |-
#!/bin/ash
# Sponge Installation Script
#
# Server Files: /mnt/server
cd /mnt/server
if [ $MINECRAFT_VERSION = 'latest' ] || [ -z $MINECRAFT_VERSION ]; then
TARGET_VERSION_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/latest?recommended=true)
if [ -z "${TARGET_VERSION_JSON}" ]; then
echo -e "Failed to find latest recommended version!"
exit 1
fi
echo -e "Found latest version for ${SPONGE_TYPE}"
else
if [ $SPONGE_TYPE = 'spongevanilla' ]; then
VERSIONS_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/versions?tags=,minecraft:${MINECRAFT_VERSION}&offset=0&limit=1)
else
FORGETAG='forge'
if [ $SPONGE_TYPE = 'spongeneo' ]; then
FORGETAG='neoforge'
fi
VERSIONS_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/versions?tags=,minecraft:${MINECRAFT_VERSION},${FORGETAG}:${FORGE_VERSION}&offset=0&limit=1)
fi
if [ -z "${VERSIONS_JSON}" ]; then
echo -e "Failed to find recommended ${MINECRAFT_VERSION} version for ${SPONGE_TYPE} ${FORGE_VERSION}!"
exit 1
fi
VERSION_KEY=$(echo $VERSIONS_JSON | jq -r '.artifacts | to_entries[0].key')
TARGET_VERSION_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/versions/${VERSION_KEY})
if [ -z "${TARGET_VERSION_JSON}" ]; then
echo -e "Failed to find ${VERSION_KEY} for ${SPONGE_TYPE} ${FORGE_VERSION}!"
exit 1
fi
echo -e "Found ${MINECRAFT_VERSION} for ${SPONGE_TYPE}"
fi
TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == "universal")'`
if [ -z "${TARGET_VERSION}" ]; then
TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == "" and .extension == "jar")'`
fi
if [ -z "${TARGET_VERSION}" ]; then
echo -e "Failed to get download url data from the selected version"
exit 1
fi
SPONGE_URL=$(echo $TARGET_VERSION | jq -r '.downloadUrl')
CHECKSUM=$(echo $TARGET_VERSION | jq -r '.md5')
echo -e "Found file at ${SPONGE_URL} with checksum ${CHECKSUM}"
echo -e "running: curl -o ${SERVER_JARFILE} ${SPONGE_URL}"
curl -o ${SERVER_JARFILE} ${SPONGE_URL}
if [ $(basename $(md5sum ${SERVER_JARFILE})) = ${CHECKSUM} ] ; then
echo "Checksum passed"
else
echo "Checksum failed"
fi
echo -e "Install Complete"
container: 'ghcr.io/parkervcp/installers:alpine'
entrypoint: ash
variables:
-
name: 'Forge/Neoforge Version'
description: |-
The modding api target version if set to `spongeforge` or `spongeneo`. Leave blank if using
`spongevanilla`
env_variable: FORGE_VERSION
default_value: ''
user_viewable: true
user_editable: true
rules:
- string
sort: 3
-
name: 'Minecraft Version'
description: |-
The version of Minecraft to target. Use "latest" to install the latest version. Go to Settings >
Reinstall Server to apply.
env_variable: MINECRAFT_VERSION
default_value: latest
user_viewable: true
user_editable: true
rules:
- required
- string
- 'between:3,15'
sort: 1
-
name: 'Server Jar File'
description: 'The name of the Jarfile to use when running Sponge.'
env_variable: SERVER_JARFILE
default_value: server.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 4
-
name: 'Sponge Type'
description: |-
SpongeVanilla if you are only using Sponge plugins.
SpongeForge when using Forge mods and Sponge plugins.
SpongeNeo when using NeoForge mods and Sponge plugins.
env_variable: SPONGE_TYPE
default_value: spongevanilla
user_viewable: true
user_editable: true
rules:
- required
- 'in:spongevanilla,spongeforge,spongeneo'
sort: 2

View File

@ -1,71 +0,0 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL",
"meta": {
"version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/minecraft\/egg-vanilla-minecraft.json"
},
"exported_at": "2025-03-18T12:35:55+00:00",
"name": "Vanilla Minecraft",
"author": "panel@example.com",
"uuid": "9ac39f3d-0c34-4d93-8174-c52ab9e6c57b",
"description": "Minecraft is a game about placing blocks and going on adventures. Explore randomly generated worlds and build amazing things from the simplest of homes to the grandest of castles. Play in Creative Mode with unlimited resources or mine deep in Survival Mode, crafting weapons and armor to fend off dangerous mobs. Do all this alone or with friends.",
"tags": [
"minecraft"
],
"features": [
"eula",
"java_version",
"pid_limit"
],
"docker_images": {
"Java 21": "ghcr.io\/parkervcp\/yolks:java_21",
"Java 17": "ghcr.io\/parkervcp\/yolks:java_17",
"Java 16": "ghcr.io\/parkervcp\/yolks:java_16",
"Java 11": "ghcr.io\/parkervcp\/yolks:java_11",
"Java 8": "ghcr.io\/parkervcp\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}",
"config": {
"files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.allocations.default.port}}\",\r\n \"query.port\": \"{{server.allocations.default.port}}\"\r\n }\r\n }\r\n}",
"startup": "{\r\n \"done\": \")! For help, type \"\r\n}",
"logs": "{}",
"stop": "stop"
},
"scripts": {
"installation": {
"script": "#!\/bin\/ash\r\n# Vanilla MC Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\nLATEST_VERSION=`curl https:\/\/launchermeta.mojang.com\/mc\/game\/version_manifest.json | jq -r '.latest.release'`\r\nLATEST_SNAPSHOT_VERSION=`curl https:\/\/launchermeta.mojang.com\/mc\/game\/version_manifest.json | jq -r '.latest.snapshot'`\r\n\r\necho -e \"latest version is $LATEST_VERSION\"\r\necho -e \"latest snapshot is $LATEST_SNAPSHOT_VERSION\"\r\n\r\nif [ -z \"$VANILLA_VERSION\" ] || [ \"$VANILLA_VERSION\" == \"latest\" ]; then\r\n MANIFEST_URL=$(curl -sSL https:\/\/launchermeta.mojang.com\/mc\/game\/version_manifest.json | jq --arg VERSION $LATEST_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')\r\nelif [ \"$VANILLA_VERSION\" == \"snapshot\" ]; then\r\n MANIFEST_URL=$(curl -sSL https:\/\/launchermeta.mojang.com\/mc\/game\/version_manifest.json | jq --arg VERSION $LATEST_SNAPSHOT_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')\r\nelse\r\n MANIFEST_URL=$(curl -sSL https:\/\/launchermeta.mojang.com\/mc\/game\/version_manifest.json | jq --arg VERSION $VANILLA_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')\r\nfi\r\n\r\nDOWNLOAD_URL=$(curl ${MANIFEST_URL} | jq .downloads.server | jq -r '. | .url')\r\n\r\necho -e \"running: curl -o ${SERVER_JARFILE} $DOWNLOAD_URL\"\r\ncurl -o ${SERVER_JARFILE} $DOWNLOAD_URL\r\n\r\necho -e \"Install Complete\"",
"container": "ghcr.io\/parkervcp\/installers:alpine",
"entrypoint": "ash"
}
},
"variables": [
{
"name": "Server Jar File",
"description": "The name of the server jarfile to run the server with.",
"env_variable": "SERVER_JARFILE",
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
],
"sort": 1
},
{
"name": "Server Version",
"description": "The version of Minecraft Vanilla to install. Use \"latest\" to install the latest version, or use \"snapshot\" to install the latest snapshot. Go to Settings > Reinstall Server to apply.",
"env_variable": "VANILLA_VERSION",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"string",
"between:3,15"
],
"sort": 2
}
]
}

View File

@ -0,0 +1,97 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v2
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-vanilla-minecraft.yaml'
exported_at: '2025-08-05T20:59:51+00:00'
name: 'Vanilla Minecraft'
author: panel@example.com
uuid: 9ac39f3d-0c34-4d93-8174-c52ab9e6c57b
description: |-
Minecraft is a game about placing blocks and going on adventures. Explore randomly generated worlds
and build amazing things from the simplest of homes to the grandest of castles. Play in Creative
Mode with unlimited resources or mine deep in Survival Mode, crafting weapons and armor to fend off
dangerous mobs. Do all this alone or with friends.
tags:
- minecraft
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/parkervcp/yolks:java_21'
'Java 17': 'ghcr.io/parkervcp/yolks:java_17'
'Java 16': 'ghcr.io/parkervcp/yolks:java_16'
'Java 11': 'ghcr.io/parkervcp/yolks:java_11'
'Java 8': 'ghcr.io/parkervcp/yolks:java_8'
file_denylist: { }
startup: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}'
config:
files:
server.properties:
parser: properties
find:
server-ip: ''
server-port: '{{server.allocations.default.port}}'
query.port: '{{server.allocations.default.port}}'
startup:
done: ')! For help, type '
logs: { }
stop: stop
scripts:
installation:
script: |-
#!/bin/ash
# Vanilla MC Installation Script
#
# Server Files: /mnt/server
mkdir -p /mnt/server
cd /mnt/server
LATEST_VERSION=`curl https://launchermeta.mojang.com/mc/game/version_manifest.json | jq -r '.latest.release'`
LATEST_SNAPSHOT_VERSION=`curl https://launchermeta.mojang.com/mc/game/version_manifest.json | jq -r '.latest.snapshot'`
echo -e "latest version is $LATEST_VERSION"
echo -e "latest snapshot is $LATEST_SNAPSHOT_VERSION"
if [ -z "$VANILLA_VERSION" ] || [ "$VANILLA_VERSION" == "latest" ]; then
MANIFEST_URL=$(curl -sSL https://launchermeta.mojang.com/mc/game/version_manifest.json | jq --arg VERSION $LATEST_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')
elif [ "$VANILLA_VERSION" == "snapshot" ]; then
MANIFEST_URL=$(curl -sSL https://launchermeta.mojang.com/mc/game/version_manifest.json | jq --arg VERSION $LATEST_SNAPSHOT_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')
else
MANIFEST_URL=$(curl -sSL https://launchermeta.mojang.com/mc/game/version_manifest.json | jq --arg VERSION $VANILLA_VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')
fi
DOWNLOAD_URL=$(curl ${MANIFEST_URL} | jq .downloads.server | jq -r '. | .url')
echo -e "running: curl -o ${SERVER_JARFILE} $DOWNLOAD_URL"
curl -o ${SERVER_JARFILE} $DOWNLOAD_URL
echo -e "Install Complete"
container: 'ghcr.io/parkervcp/installers:alpine'
entrypoint: ash
variables:
-
name: 'Server Jar File'
description: 'The name of the server jarfile to run the server with.'
env_variable: SERVER_JARFILE
default_value: server.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 1
-
name: 'Server Version'
description: |-
The version of Minecraft Vanilla to install. Use "latest" to install the latest version, or use
"snapshot" to install the latest snapshot. Go to Settings > Reinstall Server to apply.
env_variable: VANILLA_VERSION
default_value: latest
user_viewable: true
user_editable: true
rules:
- required
- string
- 'between:3,15'
sort: 2

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