Compare commits

...

5 Commits

Author SHA1 Message Date
8573d96719 Bump VERSION to 0.9-rc57 2026-02-09 14:58:24 +02:00
800e07d2ba Update onboarding, support pages, and deploy tooling 2026-02-09 14:58:04 +02:00
c6f5b6cab8 Replace custom HTML activity log table with Filament EmbeddedTable
The activity tab on the user Logs page used a raw HTML table with
Tailwind classes. This replaces it with a proper Filament embedded
table widget (ActivityLogTable) for consistent styling, pagination,
badges, and dark mode support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:50:49 +00:00
root
8acc55a799 Refine database warning banner and docs 2026-02-06 18:46:16 +00:00
root
a5742a3156 Add confirmations for service disabling 2026-02-06 17:00:13 +00:00
34 changed files with 829 additions and 90 deletions

View File

@@ -620,7 +620,15 @@ All administrative actions are logged to the `audit_logs` table.
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop
### Warning Banners
- Use Filament `Section::make()` for warning banners (no raw HTML).
- Always set `->icon('heroicon-o-exclamation-triangle')` and `->iconColor('warning')`.
- Keep banners non-collapsible: `->collapsed(false)->collapsible(false)`.
- Put the full message in `->description()` and keep the heading short.
### Allowed Components
Use these Filament native components exclusively:
| Category | Components |

View File

@@ -1 +1 @@
VERSION=0.9-rc54
VERSION=0.9-rc57

View File

@@ -16,7 +16,9 @@ use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
@@ -50,6 +52,13 @@ class Dashboard extends Page implements HasActions, HasForms
];
}
public function mount(): void
{
if (! DnsSetting::get('onboarding_completed', false)) {
$this->defaultAction = 'onboarding';
}
}
protected function getForms(): array
{
return [
@@ -78,21 +87,63 @@ class Dashboard extends Page implements HasActions, HasForms
->color('gray')
->action(fn () => $this->redirect(request()->url())),
Action::make('onboarding')
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.'))
->modalWidth('md')
->modalWidth('2xl')
->form([
Section::make(__('Next Steps'))
->description(__('Here is a quick setup path to get your first site online.'))
->icon('heroicon-o-check-circle')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact()
->schema([
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('1. Configure Server Settings'))
->description(__('Set hostname, DNS, email, storage, and PHP defaults.'))
->icon('heroicon-o-cog-6-tooth')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('2. Create a Hosting Package'))
->description(__('Define limits and features for your plans.'))
->icon('heroicon-o-cube')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('3. Create a User'))
->description(__('Assign the hosting package and set credentials.'))
->icon('heroicon-o-user-plus')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('4. Add a Domain'))
->description(__('Issue SSL and deploy your site files.'))
->icon('heroicon-o-globe-alt')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
]),
Text::make(__('Optional: review Services and Server Status to confirm everything is healthy.'))
->color('gray'),
]),
TextInput::make('admin_email')
->label(__('Your Email Address'))
->helperText(__('Enter your email to receive important server notifications.'))
->email()
->placeholder(__('admin@example.com')),
])
->modalSubmitActionLabel(__('Get Started'))
->modalSubmitActionLabel(__("Don't show again"))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);

View File

@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
->visible(fn () => $this->fail2banRunning)
->requiresConfirmation()
->modalHeading(__('Disable Fail2ban'))
->modalDescription(__('Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(__('Warning: Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
->action('disableFail2ban'),
])
->schema([
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
->size('sm')
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
->requiresConfirmation(fn () => ! $this->clamavRunning)
->requiresConfirmation()
->modalHeading(fn () => $this->clamavRunning ? __('Disable ClamAV') : __('Enable ClamAV'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn () => $this->clamavRunning
? __('ClamAV will be stopped and disabled. You can re-enable it later.')
? __('Warning: This will stop and disable ClamAV. You can re-enable it later.')
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
FormAction::make('updateSignatures')
->label(__('Update Signatures'))

View File

@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
protected function databaseTabContent(): array
{
return [
Section::make(__('Warning: Changing database settings can impact performance or cause outages'))
->description(__('Apply changes only if you understand their effects, and prefer doing so during maintenance windows.'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('Database Tuning'))
->description(__('Adjust MariaDB/MySQL global variables.'))
->icon('heroicon-o-circle-stack')
->schema([
Placeholder::make('database_tuning_warning')
->content(new HtmlString(
'<div class="rounded-lg bg-warning-500/10 p-4 text-sm text-warning-700 dark:text-warning-400">'.
'<strong>'.__('Warning:').'</strong> '.
__('Changing database settings can impact performance or cause outages. Apply changes only if you understand their effects, and prefer doing so during maintenance windows.').
'</div>'
)),
EmbeddedTable::make(DatabaseTuningTable::class),
]),
];

View File

@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_active'])
->requiresConfirmation()
->modalHeading(__('Stop Service'))
->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']]))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn (array $record): string => __('Warning: This will stop :service and may affect running websites and services. Are you sure you want to continue?', ['service' => $record['name']]))
->modalSubmitActionLabel(__('Stop Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
Action::make('restart')
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_enabled'])
->requiresConfirmation()
->modalHeading(__('Disable Service'))
->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']]))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn (array $record): string => __('Warning: This will disable :service and it will not start automatically on boot. Are you sure you want to continue?', ['service' => $record['name']]))
->modalSubmitActionLabel(__('Disable Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
])

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Contracts\Support\Htmlable;
class Support extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
protected static ?int $navigationSort = 23;
protected static ?string $slug = 'support';
protected string $view = 'filament.admin.pages.support';
public static function getNavigationLabel(): string
{
return __('Support');
}
public function getTitle(): string|Htmlable
{
return __('Support');
}
}

View File

@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
use App\Filament\Jabali\Widgets\MailboxesWidget;
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
use App\Filament\Jabali\Widgets\StatsOverview;
use App\Models\DnsSetting;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Illuminate\Support\Facades\Auth;
class Dashboard extends BaseDashboard
@@ -41,6 +46,75 @@ class Dashboard extends BaseDashboard
];
}
public function mount(): void
{
if (! DnsSetting::get('user_onboarding_completed_'.(string) Auth::id(), false)) {
$this->defaultAction = 'onboarding';
}
}
protected function getHeaderActions(): array
{
return [
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Here is a quick path to launch your first site.'))
->modalWidth('2xl')
->form([
Section::make(__('Next Steps'))
->description(__('Follow these steps to get online quickly.'))
->icon('heroicon-o-check-circle')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact()
->schema([
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('1. Add a Domain'))
->description(__('Point your DNS to this server or update nameservers.'))
->icon('heroicon-o-globe-alt')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('2. Issue SSL'))
->description(__('Enable HTTPS for your site with SSL certificates.'))
->icon('heroicon-o-lock-closed')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('3. Upload or Install'))
->description(__('Upload files or install WordPress to deploy your site.'))
->icon('heroicon-o-arrow-up-tray')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('4. Create Email & Databases'))
->description(__('Set up mailboxes and databases for your app.'))
->icon('heroicon-o-envelope')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
]),
Text::make(__('Optional: configure backups, cron jobs, and SSH keys for day-to-day operations.'))
->color('gray'),
]),
])
->modalSubmitActionLabel(__("Don't show again"))
->action(function (): void {
DnsSetting::set('user_onboarding_completed_'.(string) Auth::id(), '1');
DnsSetting::clearCache();
}),
];
}
public function getSubheading(): ?string
{
$user = Auth::user();

View File

@@ -32,6 +32,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
->searchable(),
->searchable()
->live()
->live(),
TextInput::make('local_part')
->label(__('Email Address'))
->required()
->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('name')
->label(__('Display Name'))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->maxLength(255),
TextInput::make('password')
->label(__('Password'))
->password()
->revealable()
->required()
->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->minLength(8)
->rules([
'regex:/[a-z]/', // lowercase
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
TextInput::make('quota_mb')
->label(__('Quota (MB)'))
->numeric()
->visible(fn (Get $get): bool => filled($get('domain_id')))
->default(1024)
->minValue(100)
->maxValue(10240)
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
->searchable(),
->searchable()
->live(),
TextInput::make('local_part')
->label(__('Email Address'))
->required()
->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('destinations')
->label(__('Forward To'))
->required()
->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->helperText(__('Comma-separated email addresses to forward to')),
])
->action(function (array $data): void {

View File

@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
public function getTitle(): string|Htmlable
{
return 'File Manager';
return __('File Manager');
}
public function getAgent(): AgentClient

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\AuditLog;
use App\Filament\Jabali\Widgets\ActivityLogTable;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
@@ -16,6 +16,7 @@ use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
@@ -119,7 +120,7 @@ class Logs extends Page implements HasActions, HasForms
'activity' => Tab::make(__('Activity Log'))
->icon('heroicon-o-clipboard-document-list')
->schema([
View::make('filament.jabali.pages.logs-tab-activity'),
EmbeddedTable::make(ActivityLogTable::class),
]),
]),
]);
@@ -227,15 +228,6 @@ class Logs extends Page implements HasActions, HasForms
->send();
}
public function getActivityLogs()
{
return AuditLog::query()
->where('user_id', Auth::id())
->latest()
->limit(50)
->get();
}
public function generateStats(): void
{
if (! $this->selectedDomain) {

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Contracts\Support\Htmlable;
class Support extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
protected static ?int $navigationSort = 23;
protected static ?string $slug = 'support';
protected string $view = 'filament.jabali.pages.support';
public static function getNavigationLabel(): string
{
return __('Support');
}
public function getTitle(): string|Htmlable
{
return __('Support');
}
}

View File

@@ -22,6 +22,7 @@ use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable;
@@ -448,24 +449,29 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->options($domainOptions)
->required()
->searchable()
->live()
->placeholder(__('Select a domain...'))
->helperText(__('The domain where WordPress will be installed')),
Toggle::make('use_www')
->label(__('Use www prefix'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install on www.domain.com instead of domain.com'))
->default(false),
TextInput::make('path')
->label(__('Directory (optional)'))
->visible(fn (Get $get): bool => filled($get('domain')))
->placeholder(__('Leave empty to install in root'))
->helperText(__('e.g., "blog" to install at domain.com/blog')),
TextInput::make('site_title')
->label(__('Site Title'))
->required()
->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default(__('My WordPress Site'))
->helperText(__('The name of your WordPress site')),
TextInput::make('admin_user')
->label(__('Admin Username'))
->required()
->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default('admin')
->alphaNum()
->helperText(__('Username for the WordPress admin account')),
@@ -473,7 +479,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->label(__('Admin Password'))
->password()
->revealable()
->required()
->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default(fn () => $this->generateSecurePassword())
->minLength(8)
->rules([
@@ -504,7 +511,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
TextInput::make('admin_email')
->label(__('Admin Email'))
->required()
->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->email()
->default(Auth::user()->email ?? '')
->helperText(__('Email address for the WordPress admin account')),
@@ -538,14 +546,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
])
->default('en_US')
->searchable()
->required()
->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Default language for WordPress admin and content')),
Toggle::make('enable_cache')
->label(__('Enable Jabali Cache'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install Redis object caching for better performance'))
->default(true),
Toggle::make('enable_auto_update')
->label(__('Enable Auto-Updates'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Automatically update WordPress, plugins, and themes'))
->default(false),
])

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Widgets;
use App\Models\AuditLog;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ActivityLogTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public function table(Table $table): Table
{
return $table
->query(
AuditLog::query()
->where('user_id', Auth::id())
->latest()
)
->columns([
TextColumn::make('created_at')
->label(__('Time'))
->dateTime('M d, H:i')
->color('gray'),
TextColumn::make('category')
->label(__('Category'))
->badge()
->color(fn (string $state): string => match ($state) {
'domain' => 'info',
'email' => 'primary',
'database' => 'warning',
'auth' => 'gray',
'firewall' => 'danger',
'service' => 'success',
default => 'gray',
}),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match ($state) {
'create', 'created' => 'success',
'update', 'updated' => 'warning',
'delete', 'deleted' => 'danger',
'login' => 'info',
default => 'gray',
}),
TextColumn::make('description')
->label(__('Description'))
->limit(60)
->wrap(),
TextColumn::make('ip_address')
->label(__('IP'))
->color('gray'),
])
->defaultPaginationPageOption(25)
->striped()
->emptyStateHeading(__('No activity recorded yet'))
->emptyStateDescription(__('Recent actions performed in your account will appear here.'))
->emptyStateIcon('heroicon-o-clipboard-document-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -185,3 +185,5 @@ class BackupSchedule extends Model
return $timezone;
}
}

View File

@@ -17,7 +17,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"filament/blueprint": "^2.0",
"filament/blueprint": "^2.1",
"laravel/boost": "*",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",

8
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "194d87cc129a30c6e832109fb820097a",
"content-hash": "7083b0b087c4b503b50d3aa23cfbbfac",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -8817,14 +8817,14 @@
},
{
"name": "filament/blueprint",
"version": "v2.0.1",
"version": "v2.1.0",
"dist": {
"type": "zip",
"url": "https://packages.filamentphp.com/composer/10/127/download"
"url": "https://packages.filamentphp.com/composer/10/473/download"
},
"require": {
"filament/support": "^5.0",
"laravel/boost": "^1.8"
"laravel/boost": "^1.8|^2.0"
},
"type": "library",
"license": [

View File

@@ -1,6 +1,6 @@
# Jabali Documentation Index
Last updated: 2026-02-06
Last updated: 2026-02-09
## Top-Level Docs
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
@@ -11,7 +11,7 @@ Last updated: 2026-02-06
- /var/www/jabali/TODO.md - Active checklist items.
## Docs Folder
- /var/www/jabali/docs/installation.md - Debian package install path and Filament notifications patch.
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.

View File

@@ -1,6 +1,6 @@
# Documentation Summary (Jabali Panel)
Last updated: 2026-02-06
Last updated: 2026-02-09
## Product Overview
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
@@ -41,6 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
- Installer: install.sh, builds assets as www-data and ensures permissions.
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump.
## Packaging
Debian packaging is supported via scripts:

View File

@@ -110,6 +110,51 @@ What is included:
If you update or rebuild assets, keep the guard in place and hardrefresh the
browser (Ctrl+Shift+R) after deployment.
## Deploy script
The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the
project to a remote server over SSH, then runs composer/npm, migrations, and
cache rebuilds as the web user.
Defaults (override via flags or env vars):
- Host: `192.168.100.50`
- User: `root`
- Path: `/var/www/jabali`
- Web user: `www-data`
Common usage:
```
# Basic deploy to the default host
scripts/deploy.sh
# Target a different host/path/user
scripts/deploy.sh --host 192.168.100.50 --user root --path /var/www/jabali --www-user www-data
# Dry-run rsync only
scripts/deploy.sh --dry-run
# Skip npm build and cache steps
scripts/deploy.sh --skip-npm --skip-cache
```
Push to Git remotes (optional):
```
# Push to Gitea and/or GitHub before deploying
scripts/deploy.sh --push-gitea --push-github
# Push to explicit URLs
scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \
--push-github --github-url git@github.com:shukiv/jabali-panel.git
```
Notes:
- `--push-gitea` / `--push-github` require a clean worktree.
- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback,
and commits the version bump before pushing.
- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
`bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
- `--delete` passes `--delete` to rsync (dangerous).
## Testing after changes
After every change, run a test to make sure there are no errors.

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc54}"
JABALI_VERSION="${JABALI_VERSION:-0.9-rc57}"
# Colors
RED='\033[0;31m'

View File

@@ -219,6 +219,9 @@
"Disk Usage": "استخدام القرص",
"Display Name": "الاسم المعروض",
"Document Root": "المجلد الجذر",
"Documentation": "التوثيق",
"Open Documentation": "فتح التوثيق",
"Support Chat": "دردشة الدعم",
"Domain": "نطاق",
"Domain Name": "اسم النطاق",
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",
@@ -879,4 +882,4 @@
"results": "نتائج",
"selected": "محدد",
"to": "إلى"
}
}

View File

@@ -782,6 +782,8 @@
"Display Name": "Display Name",
"Document Root": "Document Root",
"Documentation": "Documentation",
"Open Documentation": "Open Documentation",
"Support Chat": "Support Chat",
"Domain": "Domain",
"Domain Aliases": "Domain Aliases",
"Domain Certificates": "Domain Certificates",

View File

@@ -308,6 +308,9 @@
"Disk Usage": "Uso de disco",
"Display Name": "Nombre para mostrar",
"Document Root": "Raíz del documento",
"Documentation": "Documentación",
"Open Documentation": "Abrir documentación",
"Support Chat": "Chat de soporte",
"Domain": "Dominio",
"Domain Configuration": "Configuración de dominio",
"Domain Count": "Cantidad de dominios",
@@ -1170,4 +1173,4 @@
"results": "resultados",
"selected": "seleccionado(s)",
"to": "a"
}
}

View File

@@ -220,6 +220,9 @@
"Disk Usage": "Utilisation du disque",
"Display Name": "Nom d'affichage",
"Document Root": "Racine du document",
"Documentation": "Documentation",
"Open Documentation": "Ouvrir la documentation",
"Support Chat": "Chat de support",
"Domain": "Domaine",
"Domain Name": "Nom de domaine",
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",
@@ -886,4 +889,4 @@
"results": "resultats",
"selected": "selectionne(s)",
"to": "a"
}
}

View File

@@ -219,6 +219,9 @@
"Disk Usage": "שימוש בדיסק",
"Display Name": "שם תצוגה",
"Document Root": "תיקיית שורש",
"Documentation": "תיעוד",
"Open Documentation": "פתח תיעוד",
"Support Chat": "צ'אט תמיכה",
"Domain": "דומיין",
"Domain Name": "שם הדומיין",
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",
@@ -879,4 +882,4 @@
"results": "תוצאות",
"selected": "נבחרו",
"to": "עד"
}
}

View File

@@ -219,6 +219,9 @@
"Disk Usage": "Uso de Disco",
"Display Name": "Nome de Exibição",
"Document Root": "Raiz do Documento",
"Documentation": "Documentação",
"Open Documentation": "Abrir documentação",
"Support Chat": "Chat de suporte",
"Domain": "Domínio",
"Domain Name": "Nome do Domínio",
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",
@@ -879,4 +882,4 @@
"results": "resultados",
"selected": "selecionado(s)",
"to": "até"
}
}

View File

@@ -220,6 +220,9 @@
"Disk Usage": "Использование диска",
"Display Name": "Отображаемое имя",
"Document Root": "Корневая директория",
"Documentation": "Документация",
"Open Documentation": "Открыть документацию",
"Support Chat": "Чат поддержки",
"Domain": "Домен",
"Domain Name": "Имя домена",
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",
@@ -886,4 +889,4 @@
"results": "результатов",
"selected": "выбрано",
"to": "до"
}
}

View File

@@ -7,3 +7,4 @@
[x-cloak] {
display: none;
}

View File

@@ -0,0 +1,32 @@
<x-filament-panels::page>
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/docs/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('Open Documentation') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-chat-bubble-left-right"
icon-color="info"
class="mt-6"
>
<x-slot name="heading">{{ __('Support Chat') }}</x-slot>
<div id="jabali-support-chat"></div>
</x-filament::section>
@script
<script src="https://portal.jabali-panel.com/js/support-widget.js?v=10" data-api-url="https://portal.jabali-panel.com" data-open="true"></script>
@endscript
</x-filament-panels::page>

View File

@@ -1,35 +0,0 @@
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Time') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Category') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Action') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Description') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('IP') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@forelse($this->getActivityLogs() as $log)
<tr>
<td class="px-4 py-3 fi-section-header-description">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->category }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->action }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->description }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->ip_address }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center fi-section-header-description">
{{ __('No activity recorded yet.') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>

View File

@@ -0,0 +1,32 @@
<x-filament-panels::page>
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/docs/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('Open Documentation') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-chat-bubble-left-right"
icon-color="info"
class="mt-6"
>
<x-slot name="heading">{{ __('Support Chat') }}</x-slot>
<div id="jabali-support-chat"></div>
</x-filament::section>
@script
<script src="https://portal.jabali-panel.com/js/support-widget.js?v=10" data-api-url="https://portal.jabali-panel.com" data-open="true"></script>
@endscript
</x-filament-panels::page>

306
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,306 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
WWW_USER="${WWW_USER:-www-data}"
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
GITEA_URL="${GITEA_URL:-}"
GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
GITHUB_URL="${GITHUB_URL:-}"
PUSH_BRANCH="${PUSH_BRANCH:-}"
SKIP_SYNC=0
SKIP_COMPOSER=0
SKIP_NPM=0
SKIP_MIGRATE=0
SKIP_CACHE=0
DELETE_REMOTE=0
DRY_RUN=0
PUSH_GITEA=0
PUSH_GITHUB=0
SET_VERSION=""
usage() {
cat <<'EOF'
Usage: scripts/deploy.sh [options]
Options:
--host HOST Remote host (default: 192.168.100.50)
--user USER SSH user (default: root)
--path PATH Remote path (default: /var/www/jabali)
--www-user USER Remote runtime user (default: www-data)
--skip-sync Skip rsync sync step
--skip-composer Skip composer install
--skip-npm Skip npm install/build
--skip-migrate Skip php artisan migrate
--skip-cache Skip cache clear/rebuild
--delete Pass --delete to rsync (dangerous)
--dry-run Dry-run rsync only
--push-gitea Push current branch to Gitea before deploy
--gitea-remote NAME Gitea git remote name (default: gitea)
--gitea-url URL Push to this URL instead of a named remote
--push-github Push current branch to GitHub before deploy
--github-remote NAME GitHub git remote name (default: origin)
--github-url URL Push to this URL instead of a named remote
--version VALUE Set VERSION to a specific value before push
-h, --help Show this help
Environment overrides:
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
DEPLOY_HOST="$2"
shift 2
;;
--user)
DEPLOY_USER="$2"
shift 2
;;
--path)
DEPLOY_PATH="$2"
shift 2
;;
--www-user)
WWW_USER="$2"
shift 2
;;
--skip-sync)
SKIP_SYNC=1
shift
;;
--skip-composer)
SKIP_COMPOSER=1
shift
;;
--skip-npm)
SKIP_NPM=1
shift
;;
--skip-migrate)
SKIP_MIGRATE=1
shift
;;
--skip-cache)
SKIP_CACHE=1
shift
;;
--delete)
DELETE_REMOTE=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--push-gitea)
PUSH_GITEA=1
shift
;;
--gitea-remote)
GITEA_REMOTE="$2"
shift 2
;;
--gitea-url)
GITEA_URL="$2"
shift 2
;;
--push-github)
PUSH_GITHUB=1
shift
;;
--github-remote)
GITHUB_REMOTE="$2"
shift 2
;;
--github-url)
GITHUB_URL="$2"
shift 2
;;
--version)
SET_VERSION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
ensure_clean_worktree() {
if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
echo "Working tree is dirty. Commit or stash changes before pushing."
exit 1
fi
}
get_current_version() {
sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION"
}
bump_version() {
local current new base num
current="$(get_current_version)"
if [[ -n "$SET_VERSION" ]]; then
new="$SET_VERSION"
else
if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
base="${BASH_REMATCH[1]}"
num="${BASH_REMATCH[2]}"
if [[ -z "$num" ]]; then
num=1
else
num=$((num + 1))
fi
new="${base}${num}"
elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
else
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly."
exit 1
fi
fi
if [[ "$new" == "$current" ]]; then
echo "VERSION is already '$current'. Use --version to set a new value."
exit 1
fi
printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION"
perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh"
git -C "$ROOT_DIR" add VERSION install.sh
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
git -C "$ROOT_DIR" commit -m "Bump VERSION to $new"
fi
}
prepare_push() {
ensure_clean_worktree
bump_version
}
push_remote() {
local label="$1"
local remote_name="$2"
local remote_url="$3"
local target
if [[ -n "$remote_url" ]]; then
target="$remote_url"
else
if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then
echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote."
exit 1
fi
target="$remote_name"
fi
if [[ -z "$PUSH_BRANCH" ]]; then
PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
fi
git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH"
}
rsync_project() {
local -a rsync_opts
rsync_opts=(-az --info=progress2)
if [[ "$DELETE_REMOTE" -eq 1 ]]; then
rsync_opts+=(--delete)
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
rsync_opts+=(--dry-run)
fi
rsync "${rsync_opts[@]}" \
--exclude ".git/" \
--exclude "node_modules/" \
--exclude "vendor/" \
--exclude "storage/" \
--exclude "bootstrap/cache/" \
--exclude "public/build/" \
--exclude ".env" \
--exclude ".env.*" \
--exclude "database/*.sqlite" \
--exclude "database/*.sqlite-wal" \
--exclude "database/*.sqlite-shm" \
"$ROOT_DIR/" \
"${REMOTE}:${DEPLOY_PATH}/"
}
remote_run() {
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc '$1'"
}
remote_run_www() {
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc 'cd \"$DEPLOY_PATH\" && sudo -u \"$WWW_USER\" -H bash -lc \"$1\"'"
}
echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
if [[ "$PUSH_GITEA" -eq 1 ]]; then
prepare_push
echo "Pushing to Gitea..."
push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL"
fi
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
if [[ "$PUSH_GITEA" -ne 1 ]]; then
prepare_push
fi
echo "Pushing to GitHub..."
push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL"
fi
if [[ "$SKIP_SYNC" -eq 0 ]]; then
echo "Syncing project files..."
rsync_project
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry run complete. No remote commands executed."
exit 0
fi
if [[ "$SKIP_COMPOSER" -eq 0 ]]; then
echo "Installing composer dependencies..."
remote_run_www "composer install --no-interaction --prefer-dist --optimize-autoloader"
fi
if [[ "$SKIP_NPM" -eq 0 ]]; then
echo "Building frontend assets..."
remote_run_www "npm ci"
remote_run_www "npm run build"
fi
if [[ "$SKIP_MIGRATE" -eq 0 ]]; then
echo "Running migrations..."
remote_run_www "php artisan migrate --force"
fi
if [[ "$SKIP_CACHE" -eq 0 ]]; then
echo "Refreshing caches..."
remote_run_www "php artisan optimize:clear"
remote_run_www "php artisan config:cache"
remote_run_www "php artisan route:cache"
remote_run_www "php artisan view:cache"
fi
echo "Deploy complete."

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Filament;
use App\Filament\Admin\Pages\Support as AdminSupport;
use App\Filament\Jabali\Pages\Support as UserSupport;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class SupportPagesTest extends TestCase
{
use RefreshDatabase;
public function test_admin_support_page_renders_docs_and_chat(): void
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
Livewire::test(AdminSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('jabali-support-chat', false);
}
public function test_user_support_page_renders_docs_and_chat(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(UserSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('jabali-support-chat', false);
}
}