mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 14:06:15 +00:00
@ -9,7 +9,7 @@ insert_final_newline = true
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
ij_any_block_comment_at_first_column = false
|
ij_any_block_comment_at_first_column = false
|
||||||
|
|
||||||
[*.php]
|
[{*.php,composer.json,composer.lock}]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
|
36
app/Console/Commands/Plugins/InstallPluginCommand.php
Normal file
36
app/Console/Commands/Plugins/InstallPluginCommand.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Plugins;
|
||||||
|
|
||||||
|
use App\Facades\Plugins;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class InstallPluginCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'plugins:install {url} {--branch=} {--tag=}';
|
||||||
|
|
||||||
|
protected $description = 'Install a plugin from a repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$url = $this->argument('url');
|
||||||
|
$branch = $this->option('branch');
|
||||||
|
$tag = $this->option('tag');
|
||||||
|
|
||||||
|
$this->info('Installing plugin from '.$url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Plugins::install($url, $branch, $tag);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->output->error($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Plugin installed successfully');
|
||||||
|
}
|
||||||
|
}
|
34
app/Console/Commands/Plugins/LoadPluginsCommand.php
Normal file
34
app/Console/Commands/Plugins/LoadPluginsCommand.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Plugins;
|
||||||
|
|
||||||
|
use App\Facades\Plugins;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class LoadPluginsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'plugins:load';
|
||||||
|
|
||||||
|
protected $description = 'Load all plugins from the storage/plugins directory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->info('Loading plugins...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Plugins::load();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->output->error($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugins::cleanup();
|
||||||
|
|
||||||
|
$this->info('Plugins loaded successfully.');
|
||||||
|
}
|
||||||
|
}
|
18
app/Console/Commands/Plugins/PluginsListCommand.php
Normal file
18
app/Console/Commands/Plugins/PluginsListCommand.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Plugins;
|
||||||
|
|
||||||
|
use App\Facades\Plugins;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PluginsListCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'plugins:list';
|
||||||
|
|
||||||
|
protected $description = 'List all installed plugins';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->table(['Name', 'Version'], Plugins::all());
|
||||||
|
}
|
||||||
|
}
|
29
app/Console/Commands/Plugins/UninstallPluginCommand.php
Normal file
29
app/Console/Commands/Plugins/UninstallPluginCommand.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Plugins;
|
||||||
|
|
||||||
|
use App\Facades\Plugins;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class UninstallPluginCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'plugins:uninstall {name}';
|
||||||
|
|
||||||
|
protected $description = 'Uninstall a plugin by name';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->info('Uninstalling '.$this->argument('name').'...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Plugins::uninstall($this->argument('name'));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->output->error($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Plugin uninstalled successfully.');
|
||||||
|
}
|
||||||
|
}
|
20
app/Facades/Plugins.php
Normal file
20
app/Facades/Plugins.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Facades;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static mixed all()
|
||||||
|
* @method static string install(string $url, ?string $branch = null, ?string $tag = null)
|
||||||
|
* @method static string load()
|
||||||
|
* @method static string uninstall(string $name)
|
||||||
|
* @method static void cleanup()
|
||||||
|
*/
|
||||||
|
class Plugins extends Facade
|
||||||
|
{
|
||||||
|
protected static function getFacadeAccessor(): string
|
||||||
|
{
|
||||||
|
return 'plugins';
|
||||||
|
}
|
||||||
|
}
|
89
app/Http/Controllers/PluginController.php
Normal file
89
app/Http/Controllers/PluginController.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Facades\Plugins;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Delete;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Get;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Middleware;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Post;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Prefix;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Prefix('settings/plugins')]
|
||||||
|
#[Middleware(['auth', 'must-be-admin'])]
|
||||||
|
class PluginController extends Controller
|
||||||
|
{
|
||||||
|
#[Get('/', name: 'plugins')]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$plugins = [];
|
||||||
|
try {
|
||||||
|
$plugins = Plugins::all();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('plugins/index', [
|
||||||
|
'plugins' => $plugins,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Post('/install', name: 'plugins.install')]
|
||||||
|
public function install(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'url' => 'required|url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! composer_path() || ! php_path()) {
|
||||||
|
return back()->with('error', 'Use CLI to install plugins.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $request->input('url');
|
||||||
|
|
||||||
|
dispatch(function () use ($url) {
|
||||||
|
try {
|
||||||
|
Plugins::install($url);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugins::cleanup();
|
||||||
|
})
|
||||||
|
->onConnection('default');
|
||||||
|
|
||||||
|
return back()->with('info', 'Plugin is being installed...');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Delete('/uninstall', name: 'plugins.uninstall')]
|
||||||
|
public function uninstall(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'name' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! composer_path() || ! php_path()) {
|
||||||
|
return back()->with('error', 'Use CLI to uninstall plugins.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $request->input('name');
|
||||||
|
|
||||||
|
dispatch(function () use ($name) {
|
||||||
|
try {
|
||||||
|
Plugins::uninstall($name);
|
||||||
|
} catch (Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugins::cleanup();
|
||||||
|
})
|
||||||
|
->onConnection('default');
|
||||||
|
|
||||||
|
return back()->with('warning', 'Plugin is being uninstalled...');
|
||||||
|
}
|
||||||
|
}
|
@ -17,7 +17,7 @@
|
|||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
||||||
#[Prefix('vito')]
|
#[Prefix('settings/vito')]
|
||||||
#[Middleware(['auth', 'must-be-admin'])]
|
#[Middleware(['auth', 'must-be-admin'])]
|
||||||
class VitoSettingController extends Controller
|
class VitoSettingController extends Controller
|
||||||
{
|
{
|
||||||
|
151
app/Plugins/Plugins.php
Normal file
151
app/Plugins/Plugins.php
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Plugins;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
|
class Plugins
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<array<string, string>>
|
||||||
|
*
|
||||||
|
* @throws FileNotFoundException
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$plugins = [];
|
||||||
|
|
||||||
|
foreach (File::directories(plugins_path()) as $vendorDir) {
|
||||||
|
foreach (File::directories($vendorDir) as $pluginDir) {
|
||||||
|
$pluginComposer = $pluginDir.'/composer.json';
|
||||||
|
if (File::exists($pluginComposer)) {
|
||||||
|
$json = json_decode(File::get($pluginComposer), true);
|
||||||
|
$plugins[] = [
|
||||||
|
'name' => $json['name'] ?? 'Unknown',
|
||||||
|
'version' => $json['version'] ?? 'Unknown',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function install(string $url, ?string $branch = null, ?string $tag = null): string
|
||||||
|
{
|
||||||
|
$vendor = str($url)->beforeLast('/')->afterLast('/');
|
||||||
|
$name = str($url)->afterLast('/');
|
||||||
|
|
||||||
|
if (is_dir(storage_path("plugins/$vendor/$name"))) {
|
||||||
|
File::deleteDirectory(storage_path("plugins/$vendor/$name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = "git clone $url ".storage_path("plugins/$vendor/$name");
|
||||||
|
if ($branch) {
|
||||||
|
$command .= " --branch $branch";
|
||||||
|
}
|
||||||
|
if ($tag) {
|
||||||
|
$command .= " --tag $tag";
|
||||||
|
}
|
||||||
|
$command .= ' --single-branch';
|
||||||
|
$result = Process::timeout(0)->run($command);
|
||||||
|
$output = $result->output();
|
||||||
|
|
||||||
|
if ($result->failed()) {
|
||||||
|
throw new Exception($output);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output .= $this->load();
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function load(): string
|
||||||
|
{
|
||||||
|
$storagePath = storage_path('plugins');
|
||||||
|
$composerJson = base_path('composer.json');
|
||||||
|
$composerLock = base_path('composer.lock');
|
||||||
|
|
||||||
|
// Backup composer files
|
||||||
|
File::copy($composerJson, $composerJson.'.bak');
|
||||||
|
File::copy($composerLock, $composerLock.'.bak');
|
||||||
|
|
||||||
|
$output = '';
|
||||||
|
|
||||||
|
foreach (File::directories($storagePath) as $vendorDir) {
|
||||||
|
foreach (File::directories($vendorDir) as $pluginDir) {
|
||||||
|
$pluginComposer = $pluginDir.'/composer.json';
|
||||||
|
if (File::exists($pluginComposer)) {
|
||||||
|
$json = json_decode(File::get($pluginComposer), true);
|
||||||
|
if (isset($json['name'])) {
|
||||||
|
$name = $json['name'];
|
||||||
|
// name must be in vendor/plugin format
|
||||||
|
if (! str_contains($name, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$phpPath = php_path();
|
||||||
|
$composerPath = composer_path();
|
||||||
|
$result = Process::timeout(0)
|
||||||
|
->env(['PATH' => dirname($phpPath).':'.dirname($composerPath)])
|
||||||
|
->path(base_path())
|
||||||
|
->run(composer_path()." require $name");
|
||||||
|
if ($result->failed()) {
|
||||||
|
throw new Exception($result->errorOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
$output .= $result->output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function uninstall(string $name): string
|
||||||
|
{
|
||||||
|
$pluginPath = storage_path('plugins/'.$name);
|
||||||
|
|
||||||
|
if (! File::exists($pluginPath)) {
|
||||||
|
throw new Exception("Plugin not found: $name");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Process::timeout(0)
|
||||||
|
->path(base_path())
|
||||||
|
->run("composer remove $name");
|
||||||
|
|
||||||
|
if ($result->failed()) {
|
||||||
|
throw new Exception($result->output());
|
||||||
|
}
|
||||||
|
|
||||||
|
File::deleteDirectory($pluginPath);
|
||||||
|
|
||||||
|
return $result->output();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cleanup(): void
|
||||||
|
{
|
||||||
|
$composerJson = base_path('composer.json');
|
||||||
|
$composerLock = base_path('composer.lock');
|
||||||
|
|
||||||
|
if (File::exists($composerJson.'.bak')) {
|
||||||
|
File::move($composerJson.'.bak', $composerJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($composerLock.'.bak')) {
|
||||||
|
File::move($composerLock.'.bak', $composerLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,7 @@
|
|||||||
use App\Helpers\Notifier;
|
use App\Helpers\Notifier;
|
||||||
use App\Helpers\SSH;
|
use App\Helpers\SSH;
|
||||||
use App\Models\PersonalAccessToken;
|
use App\Models\PersonalAccessToken;
|
||||||
|
use App\Plugins\Plugins;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
@ -29,6 +30,7 @@ public function boot(): void
|
|||||||
$this->app->bind('ssh', fn (): SSH => new SSH);
|
$this->app->bind('ssh', fn (): SSH => new SSH);
|
||||||
$this->app->bind('notifier', fn (): Notifier => new Notifier);
|
$this->app->bind('notifier', fn (): Notifier => new Notifier);
|
||||||
$this->app->bind('ftp', fn (): FTP => new FTP);
|
$this->app->bind('ftp', fn (): FTP => new FTP);
|
||||||
|
$this->app->bind('plugins', fn (): Plugins => new Plugins);
|
||||||
|
|
||||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||||
}
|
}
|
||||||
|
30
app/Providers/PluginsServiceProvider.php
Normal file
30
app/Providers/PluginsServiceProvider.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Console\Commands\Plugins\InstallPluginCommand;
|
||||||
|
use App\Console\Commands\Plugins\LoadPluginsCommand;
|
||||||
|
use App\Console\Commands\Plugins\PluginsListCommand;
|
||||||
|
use App\Plugins\Plugins;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class PluginsServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind('plugins', function () {
|
||||||
|
return new Plugins;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
InstallPluginCommand::class,
|
||||||
|
LoadPluginsCommand::class,
|
||||||
|
PluginsListCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,11 +5,7 @@
|
|||||||
use App\DTOs\DynamicField;
|
use App\DTOs\DynamicField;
|
||||||
use App\DTOs\DynamicForm;
|
use App\DTOs\DynamicForm;
|
||||||
use App\Enums\LoadBalancerMethod;
|
use App\Enums\LoadBalancerMethod;
|
||||||
use App\Plugins\RegisterSiteFeature;
|
|
||||||
use App\Plugins\RegisterSiteFeatureAction;
|
|
||||||
use App\Plugins\RegisterSiteType;
|
use App\Plugins\RegisterSiteType;
|
||||||
use App\SiteFeatures\LaravelOctane\Disable;
|
|
||||||
use App\SiteFeatures\LaravelOctane\Enable;
|
|
||||||
use App\SiteTypes\Laravel;
|
use App\SiteTypes\Laravel;
|
||||||
use App\SiteTypes\LoadBalancer;
|
use App\SiteTypes\LoadBalancer;
|
||||||
use App\SiteTypes\PHPBlank;
|
use App\SiteTypes\PHPBlank;
|
||||||
@ -114,29 +110,6 @@ private function laravel(): void
|
|||||||
->default(false),
|
->default(false),
|
||||||
]))
|
]))
|
||||||
->register();
|
->register();
|
||||||
RegisterSiteFeature::make('laravel', 'laravel-octane')
|
|
||||||
->label('Laravel Octane')
|
|
||||||
->description('Enable Laravel Octane for this site')
|
|
||||||
->register();
|
|
||||||
RegisterSiteFeatureAction::make('laravel', 'laravel-octane', 'enable')
|
|
||||||
->label('Enable')
|
|
||||||
->form(DynamicForm::make([
|
|
||||||
DynamicField::make('alert')
|
|
||||||
->alert()
|
|
||||||
->label('Alert')
|
|
||||||
->description('Make sure you have already set the `OCTANE_SERVER` in your `.env` file'),
|
|
||||||
DynamicField::make('port')
|
|
||||||
->text()
|
|
||||||
->label('Octane Port')
|
|
||||||
->default(8000)
|
|
||||||
->description('The port on which Laravel Octane will run.'),
|
|
||||||
]))
|
|
||||||
->handler(Enable::class)
|
|
||||||
->register();
|
|
||||||
RegisterSiteFeatureAction::make('laravel', 'laravel-octane', 'disable')
|
|
||||||
->label('Disable')
|
|
||||||
->handler(Disable::class)
|
|
||||||
->register();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadBalancer(): void
|
public function loadBalancer(): void
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\SiteFeatures\LaravelOctane;
|
|
||||||
|
|
||||||
use App\Actions\Worker\DeleteWorker;
|
|
||||||
use App\DTOs\DynamicField;
|
|
||||||
use App\DTOs\DynamicForm;
|
|
||||||
use App\Models\Worker;
|
|
||||||
use App\SiteFeatures\Action;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class Disable extends Action
|
|
||||||
{
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Disable';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function active(): bool
|
|
||||||
{
|
|
||||||
return data_get($this->site->type_data, 'octane', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function form(): ?DynamicForm
|
|
||||||
{
|
|
||||||
return DynamicForm::make([
|
|
||||||
DynamicField::make('port')
|
|
||||||
->text()
|
|
||||||
->default(8000),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(Request $request): void
|
|
||||||
{
|
|
||||||
$typeData = $this->site->type_data ?? [];
|
|
||||||
|
|
||||||
/** @var ?Worker $worker */
|
|
||||||
$worker = $this->site->workers()->where('name', 'laravel-octane')->first();
|
|
||||||
if ($worker) {
|
|
||||||
app(DeleteWorker::class)->delete($worker);
|
|
||||||
}
|
|
||||||
|
|
||||||
data_set($typeData, 'octane', false);
|
|
||||||
data_set($typeData, 'octane_port', $request->input('port'));
|
|
||||||
$this->site->type_data = $typeData;
|
|
||||||
$this->site->save();
|
|
||||||
|
|
||||||
$webserver = $this->site->webserver()->id();
|
|
||||||
|
|
||||||
if ($webserver === 'nginx') {
|
|
||||||
$this->site->webserver()->updateVHost(
|
|
||||||
$this->site,
|
|
||||||
replace: [
|
|
||||||
'laravel-octane-map' => '',
|
|
||||||
'laravel-octane' => view('ssh.services.webserver.nginx.vhost-blocks.php', ['site' => $this->site]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->flash('success', 'Laravel Octane has been disabled for this site.');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\SiteFeatures\LaravelOctane;
|
|
||||||
|
|
||||||
use App\Actions\Worker\CreateWorker;
|
|
||||||
use App\Actions\Worker\ManageWorker;
|
|
||||||
use App\DTOs\DynamicField;
|
|
||||||
use App\DTOs\DynamicForm;
|
|
||||||
use App\Exceptions\SSHError;
|
|
||||||
use App\Models\Worker;
|
|
||||||
use App\SiteFeatures\Action;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class Enable extends Action
|
|
||||||
{
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Enable';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function active(): bool
|
|
||||||
{
|
|
||||||
return ! data_get($this->site->type_data, 'octane', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function form(): ?DynamicForm
|
|
||||||
{
|
|
||||||
return DynamicForm::make([
|
|
||||||
DynamicField::make('port')
|
|
||||||
->text()
|
|
||||||
->default(8000),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws SSHError
|
|
||||||
*/
|
|
||||||
public function handle(Request $request): void
|
|
||||||
{
|
|
||||||
Validator::make($request->all(), [
|
|
||||||
'port' => 'required|integer|min:1|max:65535',
|
|
||||||
])->validate();
|
|
||||||
|
|
||||||
$this->site->server->ssh()->exec(
|
|
||||||
__('php :path/artisan octane:install --no-interaction', [
|
|
||||||
'path' => $this->site->path,
|
|
||||||
]),
|
|
||||||
'install-laravel-octane',
|
|
||||||
);
|
|
||||||
|
|
||||||
$command = __('php :path/artisan octane:start --port=:port --host=127.0.0.1', [
|
|
||||||
'path' => $this->site->path,
|
|
||||||
'port' => $request->input('port'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var ?Worker $worker */
|
|
||||||
$worker = $this->site->workers()->where('name', 'laravel-octane')->first();
|
|
||||||
if ($worker) {
|
|
||||||
app(ManageWorker::class)->restart($worker);
|
|
||||||
} else {
|
|
||||||
app(CreateWorker::class)->create(
|
|
||||||
$this->site->server,
|
|
||||||
[
|
|
||||||
'name' => 'laravel-octane',
|
|
||||||
'command' => $command,
|
|
||||||
'user' => $this->site->user ?? $this->site->server->getSshUser(),
|
|
||||||
'auto_start' => true,
|
|
||||||
'auto_restart' => true,
|
|
||||||
'numprocs' => 1,
|
|
||||||
],
|
|
||||||
$this->site,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$typeData = $this->site->type_data ?? [];
|
|
||||||
data_set($typeData, 'octane', true);
|
|
||||||
data_set($typeData, 'octane_port', $request->input('port'));
|
|
||||||
$this->site->type_data = $typeData;
|
|
||||||
$this->site->save();
|
|
||||||
|
|
||||||
$this->updateVHost();
|
|
||||||
|
|
||||||
$request->session()->flash('success', 'Laravel Octane has been enabled for this site.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateVHost(): void
|
|
||||||
{
|
|
||||||
$webserver = $this->site->webserver();
|
|
||||||
|
|
||||||
if ($webserver->id() === 'nginx') {
|
|
||||||
$this->site->webserver()->updateVHost(
|
|
||||||
$this->site,
|
|
||||||
replace: [
|
|
||||||
'php' => view('ssh.services.webserver.nginx.vhost-blocks.laravel-octane', ['site' => $this->site]),
|
|
||||||
'laravel-octane-map' => '',
|
|
||||||
],
|
|
||||||
append: [
|
|
||||||
'header' => view('ssh.services.webserver.nginx.vhost-blocks.laravel-octane-map', ['site' => $this->site]),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException('Unsupported webserver: '.$webserver->id());
|
|
||||||
}
|
|
||||||
}
|
|
@ -227,3 +227,41 @@ function user(): User
|
|||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plugins_path(?string $path = null): string
|
||||||
|
{
|
||||||
|
if ($path === null) {
|
||||||
|
$path = storage_path('plugins');
|
||||||
|
if (! file_exists($path)) {
|
||||||
|
mkdir($path, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage_path('plugins'.'/'.$path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function composer_path(): ?string
|
||||||
|
{
|
||||||
|
$paths = [
|
||||||
|
'/usr/local/bin/composer',
|
||||||
|
'/usr/bin/composer',
|
||||||
|
'/opt/homebrew/bin/composer',
|
||||||
|
trim((string) shell_exec('which composer')),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_find($paths, fn ($path) => is_executable($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function php_path(): ?string
|
||||||
|
{
|
||||||
|
$paths = [
|
||||||
|
'/usr/local/bin/php',
|
||||||
|
'/usr/bin/php',
|
||||||
|
'/opt/homebrew/bin/php',
|
||||||
|
trim((string) shell_exec('which php')),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_find($paths, fn ($path) => is_executable($path));
|
||||||
|
}
|
||||||
|
@ -65,6 +65,15 @@
|
|||||||
"@php artisan key:generate --ansi"
|
"@php artisan key:generate --ansi"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "path",
|
||||||
|
"url": "storage/plugins/*/*",
|
||||||
|
"options": {
|
||||||
|
"symlink": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": []
|
"dont-discover": []
|
||||||
|
569
composer.lock
generated
569
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -193,6 +193,7 @@
|
|||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
App\Providers\RouteServiceProvider::class,
|
App\Providers\RouteServiceProvider::class,
|
||||||
App\Providers\DemoServiceProvider::class,
|
App\Providers\DemoServiceProvider::class,
|
||||||
|
App\Providers\PluginsServiceProvider::class,
|
||||||
App\Providers\SiteTypeServiceProvider::class,
|
App\Providers\SiteTypeServiceProvider::class,
|
||||||
App\Providers\ServerProviderServiceProvider::class,
|
App\Providers\ServerProviderServiceProvider::class,
|
||||||
App\Providers\StorageProviderServiceProvider::class,
|
App\Providers\StorageProviderServiceProvider::class,
|
||||||
|
@ -35,7 +35,10 @@ RUN npm install --force
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
RUN composer install --no-dev
|
RUN composer install --no-dev
|
||||||
RUN chown -R www-data:www-data /var/www/html \
|
RUN chown -R www-data:www-data /var/www/html \
|
||||||
&& chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache
|
&& chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache \
|
||||||
|
|
||||||
|
# install plugins
|
||||||
|
RUN php /var/www/html/artisan plugin:install https://github.com/vitodeploy/laravel-octane-plugin
|
||||||
|
|
||||||
# webserver
|
# webserver
|
||||||
RUN rm /etc/nginx/sites-available/default
|
RUN rm /etc/nginx/sites-available/default
|
||||||
|
912
package-lock.json
generated
912
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -45,6 +45,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
@ -65,7 +66,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
|
@ -47,7 +47,7 @@ .dark {
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(51.1% 0.262 276.966);
|
--primary: oklch(0.544 0.242 279.973);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
@ -1,53 +1,142 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { DayPicker } from 'react-day-picker';
|
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = 'label',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn('p-3', className)}
|
className={cn(
|
||||||
|
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
months: 'flex flex-col sm:flex-row gap-2',
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
month: 'flex flex-col gap-4',
|
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||||
caption: 'flex justify-center pt-1 relative items-center w-full',
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
caption_label: 'text-sm font-medium',
|
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||||
nav: 'flex items-center gap-1',
|
button_previous: cn(
|
||||||
nav_button: cn(buttonVariants({ variant: 'outline' }), 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
nav_button_previous: 'absolute left-1',
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
nav_button_next: 'absolute right-1',
|
defaultClassNames.button_previous,
|
||||||
table: 'w-full border-collapse space-x-1',
|
|
||||||
head_row: 'flex',
|
|
||||||
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
|
||||||
row: 'flex w-full mt-2',
|
|
||||||
cell: cn(
|
|
||||||
'[&:has([aria-selected])]:bg-accent relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
|
||||||
props.mode === 'range'
|
|
||||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
|
||||||
: '[&:has([aria-selected])]:rounded-md',
|
|
||||||
),
|
),
|
||||||
day: cn(buttonVariants({ variant: 'ghost' }), 'size-8 p-0 font-normal aria-selected:opacity-100'),
|
button_next: cn(
|
||||||
day_range_start: 'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
|
buttonVariants({ variant: buttonVariant }),
|
||||||
day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
day_selected:
|
defaultClassNames.button_next,
|
||||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
),
|
||||||
day_today: 'bg-accent text-accent-foreground',
|
month_caption: cn('flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', defaultClassNames.month_caption),
|
||||||
day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
dropdowns: cn('flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', defaultClassNames.dropdowns),
|
||||||
day_disabled: 'text-muted-foreground opacity-50',
|
dropdown_root: cn(
|
||||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
'has-focus:border-ring border-input has-focus:ring-ring/50 relative rounded-md border shadow-xs has-focus:ring-[3px]',
|
||||||
day_hidden: 'invisible',
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
'font-medium select-none',
|
||||||
|
captionLayout === 'label'
|
||||||
|
? 'text-sm'
|
||||||
|
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5',
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
table: 'w-full border-collapse',
|
||||||
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
|
weekday: cn('text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal select-none', defaultClassNames.weekday),
|
||||||
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
|
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||||
|
week_number: cn('text-muted-foreground text-[0.8rem] select-none', defaultClassNames.week_number),
|
||||||
|
day: cn(
|
||||||
|
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
|
||||||
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
|
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
|
||||||
|
today: cn('bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', defaultClassNames.today),
|
||||||
|
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||||
|
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||||
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
IconLeft: ({ className, ...props }) => <ChevronLeft className={cn('size-4', className)} {...props} />,
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
IconRight: ({ className, ...props }) => <ChevronRight className={cn('size-4', className)} {...props} />,
|
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === 'left') {
|
||||||
|
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === 'right') {
|
||||||
|
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Calendar };
|
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
|
37
resources/js/components/ui/tabs.tsx
Normal file
37
resources/js/components/ui/tabs.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn('bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return <TabsPrimitive.Content data-slot="tabs-content" className={cn('flex-1 outline-none', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
@ -1,5 +1,5 @@
|
|||||||
import { type BreadcrumbItem, type NavItem } from '@/types';
|
import { type BreadcrumbItem, type NavItem } from '@/types';
|
||||||
import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react';
|
import { BellIcon, CloudIcon, CodeIcon, CommandIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import Layout from '@/layouts/app/layout';
|
import Layout from '@/layouts/app/layout';
|
||||||
import VitoIcon from '@/icons/vito';
|
import VitoIcon from '@/icons/vito';
|
||||||
@ -53,6 +53,11 @@ const sidebarNavItems: NavItem[] = [
|
|||||||
{
|
{
|
||||||
title: 'API Keys',
|
title: 'API Keys',
|
||||||
href: route('api-keys'),
|
href: route('api-keys'),
|
||||||
|
icon: CommandIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugins',
|
||||||
|
href: route('plugins'),
|
||||||
icon: PlugIcon,
|
icon: PlugIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
84
resources/js/pages/plugins/components/community.tsx
Normal file
84
resources/js/pages/plugins/components/community.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Repo } from '@/types/repo';
|
||||||
|
import { LoaderCircleIcon, StarIcon } from 'lucide-react';
|
||||||
|
import { CardRow } from '@/components/ui/card';
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import Install from '@/pages/plugins/components/install';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
export default function CommunityPlugins() {
|
||||||
|
const query = useInfiniteQuery<{
|
||||||
|
total_count: number;
|
||||||
|
incomplete_results: boolean;
|
||||||
|
items: Repo[];
|
||||||
|
next_page?: number;
|
||||||
|
}>({
|
||||||
|
queryKey: ['official-plugins'],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const data = (await axios.get('https://api.github.com/search/repositories?q=topic:vitodeploy-plugin&per_page=10&page=' + pageParam)).data;
|
||||||
|
data.items = data.items.filter((repo: Repo) => repo.owner.login !== 'vitodeploy');
|
||||||
|
if (data.items.length == 10) {
|
||||||
|
data.next_page = (pageParam as number) + 1;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_page,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<CardRow className="items-center justify-center">
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
</CardRow>
|
||||||
|
) : query.data && query.data.pages.length > 0 && query.data.pages[0].items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{query.data.pages.map((page) =>
|
||||||
|
page.items.map((repo) => (
|
||||||
|
<Fragment key={repo.id}>
|
||||||
|
<CardRow>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href={repo.html_url} target="_blank" className="hover:text-primary">
|
||||||
|
{repo.name}
|
||||||
|
</a>
|
||||||
|
<Badge variant="outline">by {repo.owner.login}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">{repo.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.open(repo.html_url, '_blank')}>
|
||||||
|
<StarIcon />
|
||||||
|
{repo.stargazers_count}
|
||||||
|
</Button>
|
||||||
|
<Install repo={repo} />
|
||||||
|
</div>
|
||||||
|
</CardRow>
|
||||||
|
{!(page.items[page.items.length - 1].id === repo.id && page === query.data.pages[query.data.pages.length - 1]) && (
|
||||||
|
<Separator className="my-2" />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{query.hasNextPage && (
|
||||||
|
<div className="flex items-center justify-center p-5">
|
||||||
|
<Button variant="outline" onClick={() => query.fetchNextPage()}>
|
||||||
|
{query.isFetchingNextPage && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CardRow className="items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">No plugins found</span>
|
||||||
|
</CardRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
106
resources/js/pages/plugins/components/install.tsx
Normal file
106
resources/js/pages/plugins/components/install.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Repo } from '@/types/repo';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useForm, usePage } from '@inertiajs/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DownloadIcon, LoaderCircleIcon } from 'lucide-react';
|
||||||
|
import { Form, FormField, FormFields } from '@/components/ui/form';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import InputError from '@/components/ui/input-error';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
|
||||||
|
export default function Install({ repo }: { repo?: Repo }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const page = usePage<{
|
||||||
|
plugins: Plugin[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
url: repo?.html_url || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
form.post(route('plugins.install'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button disabled={repo && page.props.plugins.filter((plugin) => plugin.name === repo.full_name).length > 0}>
|
||||||
|
<DownloadIcon />
|
||||||
|
{repo && page.props.plugins.filter((plugin) => plugin.name === repo.full_name).length > 0 ? 'Installed' : 'Install'}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Install plugin</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Install plugin {repo?.full_name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form className="p-4" id="install-plugin-form" onSubmit={submit}>
|
||||||
|
{repo ? (
|
||||||
|
<p>
|
||||||
|
Are you sure you want to install the plugin{' '}
|
||||||
|
<strong className="text-primary hover:underline">
|
||||||
|
<a href={repo.html_url} target="_blank">
|
||||||
|
{repo.full_name}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
? This will clone the repository and set it up as a Vito plugin.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<FormFields>
|
||||||
|
<FormField>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>You can use this form to install a plugin or use the following command on your Vito instance</p>
|
||||||
|
<pre className="bg-muted rounded-md px-2 py-1">
|
||||||
|
<code>php artisan plugins:install <repository-url></code>
|
||||||
|
</pre>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</FormField>
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="url">Repository URL</Label>
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
type="text"
|
||||||
|
name="url"
|
||||||
|
autoComplete="url"
|
||||||
|
value={form.data.url}
|
||||||
|
onChange={(e) => form.setData('url', e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputError message={form.errors.url} />
|
||||||
|
</FormField>
|
||||||
|
</FormFields>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={submit} disabled={form.processing}>
|
||||||
|
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
28
resources/js/pages/plugins/components/installed.tsx
Normal file
28
resources/js/pages/plugins/components/installed.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { CardRow } from '@/components/ui/card';
|
||||||
|
import React from 'react';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
import Uninstall from '@/pages/plugins/components/uninstall';
|
||||||
|
|
||||||
|
export default function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{plugins.length > 0 ? (
|
||||||
|
plugins.map((plugin, index) => (
|
||||||
|
<CardRow key={`plugin-${index}`}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">{plugin.name}</div>
|
||||||
|
<span className="text-muted-foreground text-xs">{plugin.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Uninstall plugin={plugin} />
|
||||||
|
</div>
|
||||||
|
</CardRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<CardRow className="items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">No plugins installed</span>
|
||||||
|
</CardRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
84
resources/js/pages/plugins/components/official.tsx
Normal file
84
resources/js/pages/plugins/components/official.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Repo } from '@/types/repo';
|
||||||
|
import { BadgeCheckIcon, LoaderCircleIcon, StarIcon } from 'lucide-react';
|
||||||
|
import { CardRow } from '@/components/ui/card';
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import Install from '@/pages/plugins/components/install';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
export default function OfficialPlugins() {
|
||||||
|
const query = useInfiniteQuery<{
|
||||||
|
total_count: number;
|
||||||
|
incomplete_results: boolean;
|
||||||
|
items: Repo[];
|
||||||
|
next_page?: number;
|
||||||
|
}>({
|
||||||
|
queryKey: ['official-plugins'],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const data = (
|
||||||
|
await axios.get('https://api.github.com/search/repositories?q=owner:vitodeploy%20topic:vitodeploy-plugin&per_page=10&page=' + pageParam)
|
||||||
|
).data;
|
||||||
|
if (data.items.length == 10) {
|
||||||
|
data.next_page = (pageParam as number) + 1;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_page,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<CardRow className="items-center justify-center">
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
</CardRow>
|
||||||
|
) : query.data && query.data.pages.length > 0 && query.data.pages[0].items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{query.data.pages.map((page) =>
|
||||||
|
page.items.map((repo) => (
|
||||||
|
<Fragment key={repo.id}>
|
||||||
|
<CardRow key={repo.id}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href={repo.html_url} target="_blank" className="hover:text-primary">
|
||||||
|
{repo.name}
|
||||||
|
</a>
|
||||||
|
<BadgeCheckIcon className="text-primary size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">{repo.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.open(repo.html_url, '_blank')}>
|
||||||
|
<StarIcon />
|
||||||
|
{repo.stargazers_count}
|
||||||
|
</Button>
|
||||||
|
<Install repo={repo} />
|
||||||
|
</div>
|
||||||
|
</CardRow>
|
||||||
|
{!(page.items[page.items.length - 1].id === repo.id && page === query.data.pages[query.data.pages.length - 1]) && (
|
||||||
|
<Separator className="my-2" />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{query.hasNextPage && (
|
||||||
|
<div className="flex items-center justify-center p-5">
|
||||||
|
<Button variant="outline" onClick={() => query.fetchNextPage()}>
|
||||||
|
{query.isFetchingNextPage && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CardRow className="items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">No plugins found</span>
|
||||||
|
</CardRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
58
resources/js/pages/plugins/components/uninstall.tsx
Normal file
58
resources/js/pages/plugins/components/uninstall.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { LoaderCircleIcon } from 'lucide-react';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
|
||||||
|
export default function Uninstall({ plugin }: { plugin: Plugin }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: plugin.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
form.delete(route('plugins.uninstall'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">Uninstall</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Uninstall plugin</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Uninstall plugin {plugin.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="p-4">
|
||||||
|
Are you sure you want to uninstall the plugin <strong>{plugin.name}</strong>?
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant="destructive" onClick={submit} disabled={form.processing}>
|
||||||
|
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
Uninstall
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
83
resources/js/pages/plugins/index.tsx
Normal file
83
resources/js/pages/plugins/index.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
|
import { Head, usePage } from '@inertiajs/react';
|
||||||
|
import Heading from '@/components/heading';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Container from '@/components/container';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import OfficialPlugins from '@/pages/plugins/components/official';
|
||||||
|
import InstalledPlugins from '@/pages/plugins/components/installed';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
import CommunityPlugins from '@/pages/plugins/components/community';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { BookOpenIcon } from 'lucide-react';
|
||||||
|
import Install from '@/pages/plugins/components/install';
|
||||||
|
|
||||||
|
export default function Plugins() {
|
||||||
|
const [tab, setTab] = useState('installed');
|
||||||
|
const page = usePage<{
|
||||||
|
plugins: Plugin[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<Head title="Plugins" />
|
||||||
|
|
||||||
|
<Container className="max-w-5xl">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<Heading title="Plugins" description="Here you can install/uninstall plugins" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href="https://vitodeploy.com/docs/plugins" target="_blank">
|
||||||
|
<Button variant="outline">
|
||||||
|
<BookOpenIcon />
|
||||||
|
<span className="hidden lg:block">Docs</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Install />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="installed">Installed</TabsTrigger>
|
||||||
|
<TabsTrigger value="official">Official</TabsTrigger>
|
||||||
|
<TabsTrigger value="community">Community</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="installed">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Installed plugins</CardTitle>
|
||||||
|
<CardDescription>All the installed plugins</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InstalledPlugins plugins={page.props.plugins} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="official">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Official plugins</CardTitle>
|
||||||
|
<CardDescription>These plugins are developed and maintained by VitoDeploy's team</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OfficialPlugins />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="community">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Community plugins</CardTitle>
|
||||||
|
<CardDescription>These plugins are developed and maintained by the community.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CommunityPlugins />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</Container>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
4
resources/js/types/plugin.d.ts
vendored
Normal file
4
resources/js/types/plugin.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface Plugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
102
resources/js/types/repo.d.ts
vendored
Normal file
102
resources/js/types/repo.d.ts
vendored
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
export interface Repo {
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
private: boolean;
|
||||||
|
owner: {
|
||||||
|
login: string;
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
avatar_url: string;
|
||||||
|
gravatar_id: string;
|
||||||
|
url: string;
|
||||||
|
html_url: string;
|
||||||
|
followers_url: string;
|
||||||
|
following_url: string;
|
||||||
|
gists_url: string;
|
||||||
|
starred_url: string;
|
||||||
|
subscriptions_url: string;
|
||||||
|
organizations_url: string;
|
||||||
|
repos_url: string;
|
||||||
|
events_url: string;
|
||||||
|
received_events_url: string;
|
||||||
|
type: string;
|
||||||
|
user_view_type: string;
|
||||||
|
site_admin: boolean;
|
||||||
|
};
|
||||||
|
html_url: string;
|
||||||
|
description: string | null;
|
||||||
|
fork: boolean;
|
||||||
|
url: string;
|
||||||
|
forks_url: string;
|
||||||
|
keys_url: string;
|
||||||
|
collaborators_url: string;
|
||||||
|
teams_url: string;
|
||||||
|
hooks_url: string;
|
||||||
|
issue_events_url: string;
|
||||||
|
events_url: string;
|
||||||
|
assignees_url: string;
|
||||||
|
branches_url: string;
|
||||||
|
tags_url: string;
|
||||||
|
blobs_url: string;
|
||||||
|
git_tags_url: string;
|
||||||
|
git_refs_url: string;
|
||||||
|
trees_url: string;
|
||||||
|
statuses_url: string;
|
||||||
|
languages_url: string;
|
||||||
|
stargazers_url: string;
|
||||||
|
contributors_url: string;
|
||||||
|
subscribers_url: string;
|
||||||
|
subscription_url: string;
|
||||||
|
commits_url: string;
|
||||||
|
git_commits_url: string;
|
||||||
|
comments_url: string;
|
||||||
|
issue_comment_url: string;
|
||||||
|
contents_url: string;
|
||||||
|
compare_url: string;
|
||||||
|
merges_url: string;
|
||||||
|
archive_url: string;
|
||||||
|
downloads_url: string;
|
||||||
|
issues_url: string;
|
||||||
|
pulls_url: string;
|
||||||
|
milestones_url: string;
|
||||||
|
notifications_url: string;
|
||||||
|
labels_url: string;
|
||||||
|
releases_url: string;
|
||||||
|
deployments_url: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
pushed_at: string;
|
||||||
|
git_url: string;
|
||||||
|
ssh_url: string;
|
||||||
|
clone_url: string;
|
||||||
|
svn_url: string;
|
||||||
|
homepage: string;
|
||||||
|
size: number;
|
||||||
|
stargazers_count: number;
|
||||||
|
watchers_count: number;
|
||||||
|
language: string | null;
|
||||||
|
has_issues: boolean;
|
||||||
|
has_projects: boolean;
|
||||||
|
has_downloads: boolean;
|
||||||
|
has_wiki: boolean;
|
||||||
|
has_pages: boolean;
|
||||||
|
has_discussions: boolean;
|
||||||
|
forks_count: number;
|
||||||
|
mirror_url: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
open_issues_count: number;
|
||||||
|
license?: string;
|
||||||
|
allow_forking: boolean;
|
||||||
|
is_template: boolean;
|
||||||
|
web_commit_signoff_required: boolean;
|
||||||
|
topics: string[];
|
||||||
|
visibility: string;
|
||||||
|
forks: number;
|
||||||
|
open_issues: number;
|
||||||
|
watchers: number;
|
||||||
|
default_branch: string;
|
||||||
|
score: number;
|
||||||
|
}
|
@ -201,6 +201,9 @@ echo "* * * * * cd /home/vito/vito && php artisan schedule:run >> /dev/null 2>&1
|
|||||||
# cleanup
|
# cleanup
|
||||||
chown -R vito:vito /home/vito
|
chown -R vito:vito /home/vito
|
||||||
|
|
||||||
|
# install plugins
|
||||||
|
php artisan plugin:install https://github.com/vitodeploy/laravel-octane-plugin
|
||||||
|
|
||||||
# optimize
|
# optimize
|
||||||
php artisan optimize
|
php artisan optimize
|
||||||
php artisan icons:cache
|
php artisan icons:cache
|
||||||
|
2
storage/plugins/.gitignore
vendored
Normal file
2
storage/plugins/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
Reference in New Issue
Block a user