Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
8c9a7413d0
91 changed files with 2889 additions and 2201 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ docker/coolify-realtime/node_modules
|
|||
.DS_Store
|
||||
CHANGELOG.md
|
||||
/.workspaces
|
||||
/.superpowers/
|
||||
tests/Browser/Screenshots
|
||||
tests/v4/Browser/Screenshots
|
||||
ref
|
||||
|
|
|
|||
29
app/Livewire/Dashboard/ServerMetricsChart.php
Normal file
29
app/Livewire/Dashboard/ServerMetricsChart.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ServerMetricsChart extends Component
|
||||
{
|
||||
public Server $server;
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
try {
|
||||
$this->dispatch("dashboard-server-metrics-{$this->server->uuid}", [
|
||||
'cpu' => $this->server->getCpuMetrics(10),
|
||||
'memory' => $this->server->getMemoryMetrics(10),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.dashboard.server-metrics-chart');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ class Resources extends Component
|
|||
|
||||
public array $selectedStorages = [];
|
||||
|
||||
public array $selectedVolumeStorages = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->storage);
|
||||
|
|
@ -26,6 +29,13 @@ public function mount(): void
|
|||
foreach ($backups as $backup) {
|
||||
$this->selectedStorages[$backup->id] = $this->storage->id;
|
||||
}
|
||||
|
||||
ScheduledVolumeBackup::query()
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->where('save_s3', true)
|
||||
->each(function (ScheduledVolumeBackup $backup): void {
|
||||
$this->selectedVolumeStorages[$backup->id] = $this->storage->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function disableS3(int $backupId): void
|
||||
|
|
@ -80,6 +90,61 @@ public function moveBackup(int $backupId): void
|
|||
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
|
||||
}
|
||||
|
||||
public function disableVolumeS3(int $backupId): void
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()
|
||||
->where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->firstOrFail();
|
||||
|
||||
$backup->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
unset($this->selectedVolumeStorages[$backupId]);
|
||||
|
||||
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
|
||||
}
|
||||
|
||||
public function moveVolumeBackup(int $backupId): void
|
||||
{
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()
|
||||
->where('id', $backupId)
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->firstOrFail();
|
||||
$newStorageId = $this->selectedVolumeStorages[$backupId] ?? null;
|
||||
|
||||
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
|
||||
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newStorage = S3Storage::query()
|
||||
->where('id', $newStorageId)
|
||||
->where('team_id', $this->storage->team_id)
|
||||
->first();
|
||||
|
||||
if (! $newStorage) {
|
||||
$this->dispatch('error', 'Storage not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorize('update', $newStorage);
|
||||
|
||||
$backup->update(['s3_storage_id' => $newStorage->id]);
|
||||
|
||||
unset($this->selectedVolumeStorages[$backupId]);
|
||||
|
||||
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
|
||||
|
|
@ -92,8 +157,15 @@ public function render()
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'is_usable']);
|
||||
|
||||
$volumeBackups = ScheduledVolumeBackup::query()
|
||||
->where('s3_storage_id', $this->storage->id)
|
||||
->where('save_s3', true)
|
||||
->with('backupable.resource')
|
||||
->get();
|
||||
|
||||
return view('livewire.storage.resources', [
|
||||
'groupedBackups' => $backups,
|
||||
'volumeBackups' => $volumeBackups,
|
||||
'allStorages' => $allStorages,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,69 +12,69 @@
|
|||
],
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"danharrin/livewire-rate-limiting": "^2.1.0",
|
||||
"doctrine/dbal": "^4.4.1",
|
||||
"guzzlehttp/guzzle": "^7.10.0",
|
||||
"inertiajs/inertia-laravel": "^3.1",
|
||||
"laravel/fortify": "^1.34.0",
|
||||
"laravel/framework": "^12.49.0",
|
||||
"laravel/horizon": "^5.43.0",
|
||||
"danharrin/livewire-rate-limiting": "^2.2.1",
|
||||
"doctrine/dbal": "^4.4.4",
|
||||
"guzzlehttp/guzzle": "^7.15.3",
|
||||
"inertiajs/inertia-laravel": "^3.3",
|
||||
"laravel/fortify": "^1.37.3",
|
||||
"laravel/framework": "^12.65.0",
|
||||
"laravel/horizon": "^5.48.2",
|
||||
"laravel/mcp": "^0.6.7",
|
||||
"laravel/nightwatch": "^1.24",
|
||||
"laravel/pail": "^1.2.4",
|
||||
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
|
||||
"laravel/sanctum": "^4.3.0",
|
||||
"laravel/socialite": "^5.24.2",
|
||||
"laravel/tinker": "^2.11.0",
|
||||
"laravel/ui": "^4.6.1",
|
||||
"laravel/nightwatch": "^1.28.6",
|
||||
"laravel/pail": "^1.2.7",
|
||||
"laravel/prompts": "^0.3.22|^0.3.22|^0.3.22",
|
||||
"laravel/sanctum": "^4.3.3",
|
||||
"laravel/socialite": "^5.29.0",
|
||||
"laravel/tinker": "^2.11.1",
|
||||
"laravel/ui": "^4.6.3",
|
||||
"lcobucci/jwt": "^5.6.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.31.0",
|
||||
"league/flysystem-sftp-v3": "^3.31",
|
||||
"livewire/livewire": "^3.7.8",
|
||||
"league/flysystem-aws-s3-v3": "^3.35.2",
|
||||
"league/flysystem-sftp-v3": "^3.33",
|
||||
"livewire/livewire": "^3.8.3",
|
||||
"log1x/laravel-webfonts": "^2.0.1",
|
||||
"lorisleiva/laravel-actions": "^2.9.1",
|
||||
"lorisleiva/laravel-actions": "^2.10.2",
|
||||
"nubs/random-name-generator": "^2.2",
|
||||
"phpseclib/phpseclib": "^3.0.49",
|
||||
"pion/laravel-chunk-upload": "^1.5.6",
|
||||
"poliander/cron": "^3.3.0",
|
||||
"phpseclib/phpseclib": "^3.0.56",
|
||||
"pion/laravel-chunk-upload": "^1.6.1",
|
||||
"poliander/cron": "^3.3.1",
|
||||
"purplepixie/phpdns": "^2.3.6",
|
||||
"pusher/pusher-php-server": "^7.2.7",
|
||||
"pusher/pusher-php-server": "^7.3.0",
|
||||
"resend/resend-laravel": "^0.20.0",
|
||||
"sentry/sentry-laravel": "^4.20.1",
|
||||
"socialiteproviders/authentik": "^5.2",
|
||||
"sentry/sentry-laravel": "^4.27.0",
|
||||
"socialiteproviders/authentik": "^5.3",
|
||||
"socialiteproviders/clerk": "^5.1",
|
||||
"socialiteproviders/discord": "^4.2",
|
||||
"socialiteproviders/google": "^4.1",
|
||||
"socialiteproviders/infomaniak": "^4.0",
|
||||
"socialiteproviders/microsoft-azure": "^5.2",
|
||||
"socialiteproviders/zitadel": "^4.2",
|
||||
"spatie/laravel-activitylog": "^4.11.0",
|
||||
"spatie/laravel-data": "^4.19.1",
|
||||
"spatie/laravel-markdown": "^2.7.1",
|
||||
"spatie/laravel-schemaless-attributes": "^2.5.1",
|
||||
"spatie/laravel-activitylog": "^4.12.3",
|
||||
"spatie/laravel-data": "^4.23.0",
|
||||
"spatie/laravel-markdown": "^2.8.0",
|
||||
"spatie/laravel-schemaless-attributes": "^2.6.0",
|
||||
"spatie/url": "^2.4",
|
||||
"stevebauman/purify": "^6.3.1",
|
||||
"stevebauman/purify": "^6.3.2",
|
||||
"stripe/stripe-php": "^16.6.0",
|
||||
"symfony/yaml": "^7.4.1",
|
||||
"symfony/yaml": "^7.4.15",
|
||||
"visus/cuid2": "^6.0.0",
|
||||
"yosymfony/toml": "^1.0.4",
|
||||
"zircote/swagger-php": "^5.8.0"
|
||||
"zircote/swagger-php": "^5.8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "^2.1.9",
|
||||
"driftingly/rector-laravel": "^2.5.0",
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"laravel/boost": "^2.1",
|
||||
"laravel/dusk": "^8.3.4",
|
||||
"laravel/pint": "^1.27",
|
||||
"laravel/boost": "^2.4.8",
|
||||
"laravel/dusk": "^8.6.0",
|
||||
"laravel/pint": "^1.30.4",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nunomaduro/collision": "^8.8.3",
|
||||
"pestphp/pest": "^4.3.2",
|
||||
"pestphp/pest-plugin-browser": "^4.2",
|
||||
"phpstan/phpstan": "^2.1.38",
|
||||
"rector/rector": "^2.3.5",
|
||||
"serversideup/spin": "^3.1.1",
|
||||
"spatie/laravel-ignition": "^2.10.0",
|
||||
"symfony/http-client": "^7.4.5"
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"pestphp/pest": "^4.7.8",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"phpstan/phpstan": "^2.2.8",
|
||||
"rector/rector": "^2.6.1",
|
||||
"serversideup/spin": "^3.3.0",
|
||||
"spatie/laravel-ignition": "^2.12.0",
|
||||
"symfony/http-client": "^7.4.16"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
|
|
|
|||
1235
composer.lock
generated
1235
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@
|
|||
'coolify' => [
|
||||
'version' => '4.3.0',
|
||||
'helper_version' => '1.0.14',
|
||||
'realtime_version' => '1.0.16',
|
||||
'realtime_version' => '1.0.17',
|
||||
'railpack_version' => '0.23.0',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ services:
|
|||
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
|
||||
- COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8080"
|
||||
- "${DEV_BIND_ADDRESS:-0.0.0.0}:${APP_PORT:-8000}:8080"
|
||||
- "${FORWARD_FLUX_PORT:-6443}:6443"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
@ -82,8 +82,8 @@ services:
|
|||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${FORWARD_SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
- "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_SOKETI_PORT:-6001}:6001"
|
||||
- "${DEV_BIND_ADDRESS:-0.0.0.0}:6002:6002"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
|
@ -111,8 +111,9 @@ services:
|
|||
# Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access
|
||||
VITE_HOST: "${VITE_HOST:-localhost}"
|
||||
VITE_PORT: "${VITE_PORT:-5173}"
|
||||
VITE_PROTOCOL: "${VITE_PROTOCOL:-http}"
|
||||
ports:
|
||||
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
- "${DEV_BIND_ADDRESS:-0.0.0.0}:${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
command: sh -c "npm install && npm run dev"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
26
docker/coolify-realtime/package-lock.json
generated
26
docker/coolify-realtime/package-lock.json
generated
|
|
@ -7,10 +7,10 @@
|
|||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"cookie": "2.0.1",
|
||||
"dotenv": "17.4.2",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.20.1"
|
||||
"ws": "8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
|
|
@ -29,12 +29,12 @@
|
|||
]
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
|
@ -42,9 +42,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -70,9 +70,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"cookie": "2.0.1",
|
||||
"dotenv": "17.4.2",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.20.1"
|
||||
"ws": "8.21.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import pty from 'node-pty';
|
||||
import cookie from 'cookie';
|
||||
import { parseCookie } from 'cookie';
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
extractHereDocContent,
|
||||
|
|
@ -96,7 +96,7 @@ const server = http.createServer((req, res) => {
|
|||
});
|
||||
|
||||
const getSessionCookie = (req) => {
|
||||
const cookies = cookie.parse(req.headers.cookie || '');
|
||||
const cookies = parseCookie(req.headers.cookie || '');
|
||||
const xsrfToken = cookies['XSRF-TOKEN'];
|
||||
const appName = process.env.APP_NAME || 'laravel';
|
||||
const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"version": "1.0.14"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.16"
|
||||
"version": "1.0.17"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.22"
|
||||
|
|
|
|||
1795
package-lock.json
generated
1795
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -11,31 +11,34 @@
|
|||
"clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"laravel-vite-plugin": "3.1.0",
|
||||
"postcss": "8.5.15",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"jsdom": "^30.0.1",
|
||||
"laravel-vite-plugin": "3.1.3",
|
||||
"postcss": "8.5.26",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "8.0.16",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/plugin-transform-runtime": "^7.29.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@inertiajs/react": "^3.3.0",
|
||||
"@inertiajs/vite": "^3.3.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"playwright": "^1.58.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
@import "./fonts.css" layer(base);
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@import "./utilities.css";
|
||||
|
||||
|
|
@ -307,7 +308,7 @@ a {
|
|||
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
@apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100;
|
||||
@apply outline-none ring-2 ring-coollabs dark:ring-1 dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100;
|
||||
}
|
||||
|
||||
label {
|
||||
|
|
@ -366,24 +367,24 @@ .lds-heart {
|
|||
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
/* Loading feedback uses the yellow brand accent consistently in dark mode. */
|
||||
/* Loading feedback uses the Coolify brand accent consistently in dark mode. */
|
||||
.dark .animate-spin {
|
||||
color: var(--color-warning) !important;
|
||||
color: var(--color-coollabs) !important;
|
||||
}
|
||||
|
||||
.dark #nprogress .bar {
|
||||
background: var(--color-warning) !important;
|
||||
background: var(--color-coollabs) !important;
|
||||
}
|
||||
|
||||
.dark #nprogress .peg {
|
||||
box-shadow:
|
||||
0 0 10px var(--color-warning),
|
||||
0 0 5px var(--color-warning) !important;
|
||||
0 0 10px var(--color-coollabs),
|
||||
0 0 5px var(--color-coollabs) !important;
|
||||
}
|
||||
|
||||
.dark #nprogress .spinner-icon {
|
||||
border-top-color: var(--color-warning) !important;
|
||||
border-left-color: var(--color-warning) !important;
|
||||
border-top-color: var(--color-coollabs) !important;
|
||||
border-left-color: var(--color-coollabs) !important;
|
||||
}
|
||||
|
||||
html[data-theme="custom"] .loading-indicator,
|
||||
|
|
@ -2673,6 +2674,16 @@ .dark .data-table-cell-dash {
|
|||
|
||||
/* Shared logs viewer (deployment + runtime) — mobile-first toolbar
|
||||
Mobile stacks: search → meta → full-width actions (no side-by-side overlap). */
|
||||
.logs-viewer {
|
||||
background: #fff;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.dark .logs-viewer {
|
||||
background: var(--color-log);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.logs-viewer-toolbar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
|
|
@ -2934,7 +2945,7 @@ .dark .logs-viewer-btn-active {
|
|||
|
||||
.logs-viewer-viewport {
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 0.75rem 2rem;
|
||||
}
|
||||
|
||||
.logs-viewer-line {
|
||||
|
|
@ -3024,7 +3035,7 @@ @media (min-width: 640px) {
|
|||
}
|
||||
|
||||
.logs-viewer-viewport {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.logs-viewer-line {
|
||||
|
|
|
|||
|
|
@ -131,15 +131,15 @@ @utility button {
|
|||
}
|
||||
|
||||
@utility button-highlighted {
|
||||
@apply border-2 text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;
|
||||
@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;
|
||||
}
|
||||
|
||||
@utility control-selected {
|
||||
@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;
|
||||
@apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white;
|
||||
}
|
||||
|
||||
@utility loading-indicator {
|
||||
@apply text-coollabs dark:text-warning;
|
||||
@apply text-coollabs dark:text-coollabs;
|
||||
}
|
||||
|
||||
/* Compact icon-only control (gear, chevrons, etc.) */
|
||||
|
|
@ -273,7 +273,7 @@ @utility icon {
|
|||
}
|
||||
|
||||
@utility scrollbar {
|
||||
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-warning dark:scrollbar-track-coolgray-200 scrollbar-thin;
|
||||
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-coollabs-100 dark:scrollbar-track-coolgray-200 scrollbar-thin;
|
||||
}
|
||||
|
||||
@utility main {
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ @layer base {
|
|||
}
|
||||
|
||||
:where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2;
|
||||
@apply outline-none ring-2 dark:ring-1 ring-ring ring-offset-2;
|
||||
}
|
||||
|
||||
html,
|
||||
|
|
|
|||
|
|
@ -52,12 +52,12 @@
|
|||
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Deployment',
|
||||
'label' => 'Deployment Logs',
|
||||
'route' => 'project.application.deployment.index',
|
||||
'active' => str($currentRoute)->startsWith('project.application.deployment'),
|
||||
],
|
||||
[
|
||||
'label' => 'Runtime',
|
||||
'label' => 'Runtime Logs',
|
||||
'route' => 'project.application.logs',
|
||||
'active' => $currentRoute === 'project.application.logs',
|
||||
],
|
||||
|
|
@ -142,8 +142,8 @@
|
|||
'Persistent Storage' => 'storages',
|
||||
'Backups' => 'database',
|
||||
'Terminal' => 'browser-terminal',
|
||||
'Deployment' => 'time-back',
|
||||
'Runtime' => 'unordered-list',
|
||||
'Deployment Logs' => 'time-back',
|
||||
'Runtime Logs' => 'unordered-list',
|
||||
'Git Source' => 'sources',
|
||||
'Servers' => 'servers',
|
||||
'Scheduled Tasks' => 'calendar',
|
||||
|
|
@ -160,14 +160,17 @@
|
|||
|
||||
// Discord-style groups for the settings sidebar
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'],
|
||||
'Logs' => ['Deployment', 'Runtime'],
|
||||
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
|
||||
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
||||
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
$groupedMenuItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
|
||||
->map(fn (array $labels) => collect($labels)
|
||||
->map(fn (string $label) => collect($configurationMenuItems)->firstWhere('label', $label))
|
||||
->filter()
|
||||
->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
// In-page sections (cards) shown as sub-items under the active page
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
|
||||
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)],
|
||||
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
|
||||
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
|
||||
|
|
@ -32,14 +32,18 @@
|
|||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'],
|
||||
'Automation' => ['Webhooks', 'Healthcheck'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics'],
|
||||
'Deploy' => ['Servers'],
|
||||
'Automation' => ['Webhooks', 'Backups', 'Import Backup'],
|
||||
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->map(fn (array $labels) => collect($labels)
|
||||
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
|
||||
->filter()
|
||||
->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
$pageSections = $database->type() === 'standalone-postgresql'
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@
|
|||
'value' => null, // initial value when wire=false
|
||||
'disabled' => false,
|
||||
'tooltip' => true,
|
||||
'portal' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$triggerId = ($htmlId ?? $id).'-trigger';
|
||||
$panelId = ($htmlId ?? $id).'-panel';
|
||||
@endphp
|
||||
|
||||
<div class="w-full min-w-0">
|
||||
|
|
@ -41,6 +43,7 @@
|
|||
@endif
|
||||
<div class="relative min-w-0" x-data="{
|
||||
open: false,
|
||||
positioned: false,
|
||||
options: @js(array_values($options)),
|
||||
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
|
||||
get current() {
|
||||
|
|
@ -53,11 +56,42 @@
|
|||
if (String(option.value) === String(this.value)) return;
|
||||
this.value = option.value;
|
||||
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
this.positioned = false;
|
||||
if (this.open && @js($portal)) {
|
||||
this.$nextTick(() => requestAnimationFrame(() => this.positionPanel()));
|
||||
}
|
||||
},
|
||||
positionPanel(panel = null) {
|
||||
const trigger = this.$refs.trigger;
|
||||
panel ??= document.getElementById(@js($panelId));
|
||||
if (!trigger || !panel) return;
|
||||
|
||||
const gap = 4;
|
||||
const edge = 12;
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const panelWidth = Math.max(triggerRect.width, panel.offsetWidth);
|
||||
const panelHeight = Math.min(panel.scrollHeight, 256);
|
||||
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
|
||||
const top = fitsBelow
|
||||
? triggerRect.bottom + gap
|
||||
: Math.max(edge, triggerRect.top - gap - panelHeight);
|
||||
const left = Math.min(
|
||||
Math.max(edge, triggerRect.left),
|
||||
window.innerWidth - panelWidth - edge,
|
||||
);
|
||||
|
||||
panel.style.top = `${top}px`;
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.minWidth = `${triggerRect.width}px`;
|
||||
this.positioned = true;
|
||||
}
|
||||
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
|
||||
{{ $attributes->whereStartsWith('x-effect') }}
|
||||
@click.outside="open = false" @keydown.escape="open = false">
|
||||
<button id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open"
|
||||
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
|
||||
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
|
||||
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
|
||||
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
|
||||
<span class="listbox-trigger-label" x-text="current"></span>
|
||||
|
|
@ -66,23 +100,48 @@
|
|||
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="listbox-panel" x-show="open" x-cloak role="listbox">
|
||||
<div x-show="options.length === 0"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $emptyText }}
|
||||
</div>
|
||||
<template x-for="option in options" :key="String(option.value)">
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
:class="{ 'listbox-option-disabled': option.disabled }"
|
||||
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
|
||||
<span class="truncate" x-text="option.label"></span>
|
||||
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
|
||||
class="size-3.5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
@if ($portal)
|
||||
<template x-teleport="body">
|
||||
<div id="{{ $panelId }}" class="listbox-panel" style="position: fixed; z-index: 9999" x-show="open"
|
||||
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
|
||||
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
|
||||
<div x-show="options.length === 0"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $emptyText }}
|
||||
</div>
|
||||
<template x-for="option in options" :key="String(option.value)">
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
:class="{ 'listbox-option-disabled': option.disabled }"
|
||||
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
|
||||
<span class="truncate" x-text="option.label"></span>
|
||||
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
|
||||
class="size-3.5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@else
|
||||
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak role="listbox">
|
||||
<div x-show="options.length === 0"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $emptyText }}
|
||||
</div>
|
||||
<template x-for="option in options" :key="String(option.value)">
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
:class="{ 'listbox-option-disabled': option.disabled }"
|
||||
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
|
||||
<span class="truncate" x-text="option.label"></span>
|
||||
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
|
||||
class="size-3.5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<div x-data="{
|
||||
visible: false,
|
||||
positioned: false,
|
||||
text: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
|
@ -7,7 +8,10 @@
|
|||
activeTarget: null,
|
||||
isIconAction(target) {
|
||||
if (target.matches('[data-icon-tooltip-ignore]')) return false;
|
||||
return target.matches('[data-tooltip], .icon-button') || target.querySelector('svg');
|
||||
if (target.matches('[data-tooltip], .icon-button')) return true;
|
||||
return target.hasAttribute('aria-label')
|
||||
&& target.childElementCount === 1
|
||||
&& target.firstElementChild?.matches('svg');
|
||||
},
|
||||
prepare(root) {
|
||||
root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => {
|
||||
|
|
@ -25,10 +29,12 @@
|
|||
show(event) {
|
||||
const target = this.findTarget(event);
|
||||
if (!target) return;
|
||||
if (target === this.activeTarget && this.visible) return;
|
||||
const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label');
|
||||
if (!text) return;
|
||||
this.activeTarget = target;
|
||||
this.text = text;
|
||||
this.positioned = false;
|
||||
this.visible = true;
|
||||
const rect = target.getBoundingClientRect();
|
||||
this.below = rect.top < 48;
|
||||
|
|
@ -37,11 +43,13 @@
|
|||
this.$nextTick(() => {
|
||||
const width = this.$refs.tooltip?.offsetWidth || 0;
|
||||
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
|
||||
this.$nextTick(() => this.positioned = true);
|
||||
});
|
||||
},
|
||||
hide(event) {
|
||||
if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return;
|
||||
this.visible = false;
|
||||
this.positioned = false;
|
||||
this.activeTarget = null;
|
||||
},
|
||||
init() {
|
||||
|
|
@ -62,7 +70,7 @@
|
|||
}" class="contents">
|
||||
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
|
||||
:style="`left: ${x}px; top: ${y}px;`"
|
||||
:class="below ? '' : '-translate-y-full'"
|
||||
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
|
||||
class="pointer-events-none fixed z-[100] -translate-x-1/2 whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<ul role="list" class="flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto pb-2 scrollbar">
|
||||
<ul role="list" class="-mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2 scrollbar">
|
||||
@if (isSubscribed() || !isCloud())
|
||||
{{-- Workspace --}}
|
||||
<li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
|
|
@ -31,14 +31,17 @@
|
|||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->map(fn (array $labels) => collect($labels)
|
||||
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
|
||||
->filter()
|
||||
->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
@endphp
|
||||
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
aria-label="List view" title="List view">
|
||||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
aria-label="Grid view" title="Grid view">
|
||||
<x-reicon name="grid" class="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
appearanceOpen: false,
|
||||
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
|
||||
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
|
||||
themeColorFrame: null,
|
||||
avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null),
|
||||
setTheme(type, closeMenu = true) {
|
||||
this.theme = type;
|
||||
|
|
@ -31,9 +32,29 @@
|
|||
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
},
|
||||
setThemeColor() {
|
||||
localStorage.setItem('themeColor', this.themeColor);
|
||||
this.setTheme('custom', false);
|
||||
previewThemeColor(color) {
|
||||
this.themeColor = color;
|
||||
|
||||
if (this.theme !== 'custom') {
|
||||
this.theme = 'custom';
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.dataset.theme = 'custom';
|
||||
}
|
||||
|
||||
if (this.themeColorFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.themeColorFrame = requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty('--theme-base-color', this.themeColor);
|
||||
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
|
||||
this.themeColorFrame = null;
|
||||
});
|
||||
},
|
||||
saveThemeColor(color) {
|
||||
this.previewThemeColor(color);
|
||||
localStorage.setItem('themeColor', color);
|
||||
localStorage.setItem('theme', 'custom');
|
||||
},
|
||||
}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false"
|
||||
@click.outside="open = false; appearanceOpen = false">
|
||||
|
|
@ -62,9 +83,11 @@ class="flex size-5 shrink-0 items-center justify-center rounded-full bg-neutral-
|
|||
|
||||
<template x-if="open">
|
||||
<div @class([
|
||||
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
|
||||
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible! animate-in fade-in zoom-in-95 duration-150',
|
||||
'right-0! left-auto!' => ! $sidebar,
|
||||
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
|
||||
'origin-bottom-left' => $sidebar,
|
||||
'origin-top-right' => ! $sidebar,
|
||||
])>
|
||||
<div class="min-w-0 px-2 py-1.5">
|
||||
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>
|
||||
|
|
@ -110,7 +133,8 @@ class="relative flex h-8 w-full items-center justify-between rounded-md px-2 tex
|
|||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<input type="color" x-model="themeColor" @input="setThemeColor()"
|
||||
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
|
||||
@change="saveThemeColor($event.target.value)"
|
||||
aria-label="Custom theme color"
|
||||
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,18 +56,18 @@
|
|||
}
|
||||
"
|
||||
@if ($targets) wire:target="{{ $targets }}" @endif
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
<span class="text-[13px] font-semibold leading-snug text-fg sm:whitespace-nowrap">{{ $label }}</span>
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-neutral-200 bg-white py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 dark:border-white/10 dark:bg-surface [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
<span class="text-[13px] font-semibold leading-snug text-neutral-800 dark:text-fg sm:whitespace-nowrap">{{ $label }}</span>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2">
|
||||
<button type="button" onclick="window.location.reload()"
|
||||
class="h-8 rounded-lg bg-white/[0.07] px-3.5 text-[13px] font-medium text-fg transition-colors hover:bg-white/[0.12]">
|
||||
class="h-8 rounded-lg bg-neutral-100 px-3.5 text-[13px] font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:bg-white/[0.07] dark:text-fg dark:hover:bg-white/[0.12]">
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" wire:click="{{ $action }}" wire:loading.attr="disabled"
|
||||
class="button-highlighted flex h-8 items-center gap-2 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]">
|
||||
<span>Save changes</span>
|
||||
<kbd
|
||||
class="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-white/75">Enter</kbd>
|
||||
class="rounded border border-coollabs/20 bg-coollabs/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-coollabs-200 dark:border-white/20 dark:bg-white/10 dark:text-white/75">Enter</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ class="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12px] font-
|
|||
Select where to deploy your applications and databases. You can add more servers later.
|
||||
</x-slot:question>
|
||||
<x-slot:actions>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
|
||||
<div class="w-full space-y-6">
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Add a server</h3>
|
||||
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">Use this machine or connect a server you already manage.</p>
|
||||
<div class="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<button
|
||||
class="group relative cursor-pointer min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
wire:target="setServerType('localhost')" wire:click="setServerType('localhost')">
|
||||
|
|
@ -134,26 +138,29 @@ class="absolute top-3 right-3 flex size-6 items-center justify-center rounded-fu
|
|||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="mb-1 text-[14px] font-semibold">Remote server</h3>
|
||||
<h3 class="mb-1 text-[14px] font-semibold">IP address or domain</h3>
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Connect via SSH to any server: cloud VPS, bare metal, or home infrastructure.
|
||||
Connect via SSH using a server IP address or domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Provision a server</h3>
|
||||
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">Create a server with a cloud provider.</p>
|
||||
<div class="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
@if ($currentState === 'select-server-type')
|
||||
<x-modal-input title="Connect a Hetzner Server" isFullWidth>
|
||||
<x-slot:content>
|
||||
<div
|
||||
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex h-full flex-col gap-4 text-left">
|
||||
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#D50C2D" rx="8" />
|
||||
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
<img src="{{ asset('svgs/hetzner.svg') }}" alt="Hetzner"
|
||||
class="size-10 shrink-0">
|
||||
<div class="min-h-0 flex-1">
|
||||
<h3 class="mb-1 text-[14px] font-semibold">Hetzner Cloud</h3>
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
|
|
@ -170,12 +177,8 @@ class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px
|
|||
<div
|
||||
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex h-full flex-col gap-4 text-left">
|
||||
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#007BFC" rx="8" />
|
||||
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
<img src="https://www.vultr.com/media/logo_ondark.svg" alt="Vultr"
|
||||
class="h-10 w-28 shrink-0 object-contain object-left">
|
||||
<div class="min-h-0 flex-1">
|
||||
<h3 class="mb-1 text-[14px] font-semibold">Vultr Cloud</h3>
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
|
|
@ -205,6 +208,8 @@ class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px
|
|||
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
|
|
@ -421,19 +426,13 @@ class="text-xs bg-coolgray-300 dark:bg-coolgray-400 px-1 py-0.5 rounded">~/.ssh/
|
|||
<x-forms.input placeholder="Optional: Note what this server hosts" label="Description"
|
||||
id="remoteServerDescription" wire:model="remoteServerDescription" />
|
||||
|
||||
<x-forms.collapsible title="Advanced Connection Settings"
|
||||
<x-forms.collapsible title="Advanced Settings"
|
||||
content-class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
|
||||
id="remoteServerPort" wire:model="remoteServerPort" />
|
||||
<div>
|
||||
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
|
||||
wire:model="remoteServerUser" />
|
||||
<p class="mt-1 text-xs text-black dark:text-white">
|
||||
Non-root user support is experimental.
|
||||
<a class="font-bold underline hover:text-coollabs" target="_blank"
|
||||
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
|
||||
more</a>
|
||||
</p>
|
||||
</div>
|
||||
</x-forms.collapsible>
|
||||
<x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button>
|
||||
|
|
|
|||
|
|
@ -178,15 +178,22 @@ class="button button-highlighted">
|
|||
};
|
||||
@endphp
|
||||
|
||||
<article
|
||||
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }} aria-label="Open {{ $server->name }}"
|
||||
class="group relative flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }} class="absolute inset-0 rounded-xl"
|
||||
aria-label="Open {{ $server->name }}"></a>
|
||||
@if ($server->isMetricsEnabled())
|
||||
<livewire:dashboard.server-metrics-chart :server="$server"
|
||||
:key="'dashboard-server-metrics-'.$server->uuid" />
|
||||
@endif
|
||||
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<div class="pointer-events-none relative z-10 flex min-w-0 items-start gap-3">
|
||||
<div title="{{ $serverStatus }}" aria-label="Server status: {{ $serverStatus }}"
|
||||
@class([
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-lg border bg-neutral-50 text-neutral-500 dark:bg-white/[0.04] dark:text-fg-dim',
|
||||
'border-emerald-500/70' => $serverStatusType === 'success',
|
||||
'border-amber-500/70' => $serverStatusType === 'warning',
|
||||
'border-red-500/70' => $serverStatusType === 'error',
|
||||
])>
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
@ -199,11 +206,7 @@ class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center pt-4">
|
||||
<x-status-badge :status="$serverStatus" :type="$serverStatusType" />
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border
|
|||
|
||||
<a wire:key="dashboard-active-deployment-{{ $deployment->deployment_uuid }}"
|
||||
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
|
||||
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025]">
|
||||
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
|
||||
{{ $deployment->application_name }}
|
||||
|
|
@ -139,7 +139,7 @@ class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border
|
|||
|
||||
<a wire:key="dashboard-recent-deployment-{{ $deployment->deployment_uuid }}"
|
||||
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
|
||||
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025]">
|
||||
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
|
||||
{{ $deployment->application_name }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
<div
|
||||
class="absolute right-0 bottom-0 h-2/3 w-full rounded-b-xl [&_.apexcharts-svg]:overflow-hidden [&_.apexcharts-svg]:rounded-b-xl [&_.apexcharts-tooltip]:z-30!"
|
||||
x-data="{
|
||||
hiddenAt: null,
|
||||
refreshInterval: null,
|
||||
visibilityHandler: null,
|
||||
init() {
|
||||
this.visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
this.hiddenAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hiddenAt && Date.now() - this.hiddenAt >= 60000) {
|
||||
$wire.loadData();
|
||||
}
|
||||
|
||||
this.hiddenAt = null;
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||
this.refreshInterval = window.setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
$wire.loadData();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
$wire.loadData();
|
||||
},
|
||||
destroy() {
|
||||
window.clearInterval(this.refreshInterval);
|
||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||
},
|
||||
}">
|
||||
<div wire:ignore id="dashboard-server-metrics-{{ $server->uuid }}" class="h-full w-full"></div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
(() => {
|
||||
const chart = new ApexCharts(
|
||||
document.getElementById('dashboard-server-metrics-{{ $server->uuid }}'), {
|
||||
chart: {
|
||||
height: '100%',
|
||||
type: 'area',
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
animations: { enabled: true },
|
||||
sparkline: { enabled: true },
|
||||
background: 'transparent',
|
||||
},
|
||||
series: [
|
||||
{ name: 'CPU', data: [] },
|
||||
{ name: 'Memory', data: [] },
|
||||
],
|
||||
colors: [
|
||||
'rgba(30, 144, 255, 0.52)',
|
||||
'rgba(168, 85, 247, 0.42)',
|
||||
],
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 1.5,
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
opacityFrom: 0.18,
|
||||
opacityTo: 0.02,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
grid: { show: false },
|
||||
legend: { show: false },
|
||||
markers: { size: 0 },
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
labels: { show: false },
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
intersect: false,
|
||||
marker: { show: false },
|
||||
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
|
||||
const cpu = series[0][dataPointIndex];
|
||||
const memory = series[1][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
|
||||
const formatPercent = value => Number.isFinite(value) ? `${Number(value.toFixed(1))}%` : '—';
|
||||
const formatTimestamp = timestamp => `${new Date(timestamp).toLocaleString(undefined, {
|
||||
timeZone: 'UTC',
|
||||
hour12: false,
|
||||
})} UTC`;
|
||||
|
||||
return `<div class="apexcharts-tooltip-custom">
|
||||
<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">${formatPercent(cpu)}</span></div>
|
||||
<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">${formatPercent(memory)}</span></div>
|
||||
<div class="apexcharts-tooltip-custom-title">${formatTimestamp(timestamp)}</div>
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
chart.render();
|
||||
|
||||
Livewire.on('dashboard-server-metrics-{{ $server->uuid }}', chartData => {
|
||||
chart.updateSeries([
|
||||
{
|
||||
name: 'CPU',
|
||||
data: chartData[0].cpu ?? [],
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
data: chartData[0].memory ?? [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
|
@ -120,12 +120,6 @@
|
|||
this.searchQuery = '';
|
||||
this.allSearchableItems = [];
|
||||
this.isPaletteTransitioning = false;
|
||||
// Ensure scroll is restored
|
||||
document.body.style.overflow = '';
|
||||
// Use $wire instead of @this for SPA navigation compatibility
|
||||
if ($wire) {
|
||||
$wire.closeSearchModal();
|
||||
}
|
||||
},
|
||||
runPaletteTransition(callback) {
|
||||
this.isPaletteTransitioning = true;
|
||||
|
|
@ -299,16 +293,16 @@
|
|||
}">
|
||||
|
||||
<!-- Command palette -->
|
||||
<div x-show="modalOpen" x-cloak
|
||||
<div x-cloak :class="modalOpen ? 'pointer-events-auto' : 'pointer-events-none'"
|
||||
class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
|
||||
<div x-show="modalOpen" @click="closeModal()"
|
||||
x-transition:enter="animate-in fade-in-0 duration-150"
|
||||
x-transition:leave="animate-out fade-out-0 duration-100"
|
||||
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
|
||||
</div>
|
||||
<div x-show="modalOpen" x-trap.inert="modalOpen"
|
||||
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
||||
x-transition:enter="ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-2 scale-[0.98]"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 scale-[0.98]"
|
||||
x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"
|
||||
class="command-palette relative mx-auto"
|
||||
@click.stop>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<div x-data="{
|
||||
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
|
||||
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
|
||||
themeColorFrame: null,
|
||||
init() {
|
||||
localStorage.setItem('theme', this.theme);
|
||||
this.applyTheme();
|
||||
|
|
@ -12,9 +13,29 @@
|
|||
localStorage.setItem('theme', type);
|
||||
this.applyTheme();
|
||||
},
|
||||
setThemeColor() {
|
||||
localStorage.setItem('themeColor', this.themeColor);
|
||||
this.setTheme('custom');
|
||||
previewThemeColor(color) {
|
||||
this.themeColor = color;
|
||||
|
||||
if (this.theme !== 'custom') {
|
||||
this.theme = 'custom';
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.dataset.theme = 'custom';
|
||||
}
|
||||
|
||||
if (this.themeColorFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.themeColorFrame = requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty('--theme-base-color', this.themeColor);
|
||||
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
|
||||
this.themeColorFrame = null;
|
||||
});
|
||||
},
|
||||
saveThemeColor(color) {
|
||||
this.previewThemeColor(color);
|
||||
localStorage.setItem('themeColor', color);
|
||||
localStorage.setItem('theme', 'custom');
|
||||
},
|
||||
applyTheme() {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
|
@ -76,7 +97,8 @@ class="h-8 w-20 rounded-md border border-black/10 bg-neutral-100/80 shadow-sm da
|
|||
</p>
|
||||
</div>
|
||||
@if ($option['value'] === 'custom')
|
||||
<input type="color" x-model="themeColor" @input="setThemeColor()"
|
||||
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
|
||||
@change="saveThemeColor($event.target.value)"
|
||||
aria-label="Custom theme color"
|
||||
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
|
|
@ -77,9 +77,9 @@ class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-n
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -88,7 +88,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -133,12 +133,12 @@ class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg"
|
|||
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
|
||||
<a x-show="project.addResourceHref" :href="project.addResourceHref"
|
||||
{{ wireNavigate() }}
|
||||
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
title="Add resource" :aria-label="`Add resource to ${project.name}`">
|
||||
<x-reicon name="plus" class="size-3" />
|
||||
</a>
|
||||
<a x-show="project.settingsHref" :href="project.settingsHref" {{ wireNavigate() }}
|
||||
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
title="Project settings" :aria-label="`Open settings for ${project.name}`">
|
||||
<x-reicon name="settings" class="size-3" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ class="flex size-4 shrink-0 items-center justify-center rounded-[5px] border"
|
|||
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
|
|
@ -143,9 +143,9 @@ class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-n
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -154,7 +154,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
|
|
@ -33,14 +33,17 @@
|
|||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->map(fn (array $labels) => collect($labels)
|
||||
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
|
||||
->filter()
|
||||
->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
$storageSections = $applications
|
||||
|
|
@ -113,9 +116,9 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</div>
|
||||
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
? 'control-selected'
|
||||
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
|
|
@ -123,7 +126,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
? 'control-selected'
|
||||
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
|
|
@ -99,9 +99,9 @@ class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-n
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -110,7 +110,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -152,13 +152,13 @@ class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg"
|
|||
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
|
||||
<a x-show="environment.addResourceHref" :href="environment.addResourceHref"
|
||||
{{ wireNavigate() }}
|
||||
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
title="Add resource" :aria-label="`Add resource to ${environment.name}`">
|
||||
<x-reicon name="plus" class="size-3" />
|
||||
</a>
|
||||
<a x-show="environment.settingsHref" :href="environment.settingsHref"
|
||||
{{ wireNavigate() }}
|
||||
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
title="Environment settings"
|
||||
:aria-label="`Open settings for ${environment.name}`">
|
||||
<x-reicon name="settings" class="size-3" />
|
||||
|
|
|
|||
|
|
@ -15,91 +15,89 @@
|
|||
</div>
|
||||
|
||||
@if (!$selectedType)
|
||||
<div class="application-settings-form">
|
||||
<div class="application-settings-form flex flex-col gap-6">
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-body is-flush">
|
||||
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span
|
||||
class="flex size-8 items-center justify-center rounded-lg bg-[#D50C2D] text-[12px] font-bold text-white">H</span>
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hetzner</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision from Hetzner Cloud.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span
|
||||
class="flex size-8 items-center justify-center rounded-lg bg-[#007BFC] text-[12px] font-bold text-white">V</span>
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Vultr</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision from Vultr Cloud.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<x-digital-ocean-icon class="size-8" />
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">DigitalOcean</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision a new Droplet.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'manual']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span
|
||||
class="flex size-8 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
Manual
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">IP address</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Connect an existing server over SSH.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="application-settings-section-header">
|
||||
<h2 class="application-settings-section-title">Add a server</h2>
|
||||
<p class="application-settings-section-description">Connect a server you already manage.</p>
|
||||
</div>
|
||||
<div class="application-settings-section-body is-flush">
|
||||
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="{{ route('server.create.type', ['type' => 'manual']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start">
|
||||
<span
|
||||
class="flex size-8 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">IP address or domain</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Connect an existing server over SSH.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-header">
|
||||
<h2 class="application-settings-section-title">Provision a server</h2>
|
||||
<p class="application-settings-section-description">Create a server with a cloud provider.</p>
|
||||
</div>
|
||||
<div class="application-settings-section-body is-flush">
|
||||
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start">
|
||||
<img src="{{ asset('svgs/hetzner.svg') }}" alt="Hetzner" class="size-8">
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hetzner</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision from Hetzner Cloud.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start">
|
||||
<img src="https://www.vultr.com/media/logo_ondark.svg" alt="Vultr"
|
||||
class="h-8 w-20 object-contain object-left">
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Vultr</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision from Vultr Cloud.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start">
|
||||
<x-digital-ocean-icon class="size-8" />
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">DigitalOcean</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Provision a new Droplet.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endcan
|
||||
</div>
|
||||
@else
|
||||
<div class="application-settings-form">
|
||||
|
|
|
|||
|
|
@ -41,14 +41,19 @@ class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
|
|||
default => 'Validation required',
|
||||
};
|
||||
|
||||
$statusType = match (true) {
|
||||
$isReady => 'success',
|
||||
$isTransferredAway || $server->settings->force_disabled => 'error',
|
||||
default => 'warning',
|
||||
};
|
||||
|
||||
return [
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'description' => $server->description ?: 'No description',
|
||||
'href' => route('server.show', ['server_uuid' => $server->uuid]),
|
||||
'status' => $status,
|
||||
'statusType' => $isReady ? 'success' : 'error',
|
||||
'ready' => $isReady,
|
||||
'statusType' => $statusType,
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
|
|
@ -94,9 +99,9 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
<span x-text="filteredServers.length === 1 ? 'server' : 'servers'"></span>
|
||||
</span>
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
? 'control-selected'
|
||||
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
|
|
@ -104,7 +109,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
? 'control-selected'
|
||||
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
|
|
@ -121,8 +126,9 @@ class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|||
<a :href="server.href" {{ wireNavigate() }}
|
||||
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<div :title="server.status" :aria-label="`Server status: ${server.status}`"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-neutral-50 text-neutral-500 dark:bg-white/[0.04] dark:text-fg-dim"
|
||||
:class="server.statusType === 'success' ? 'border-emerald-500/70' : server.statusType === 'warning' ? 'border-amber-500/70' : 'border-red-500/70'">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
@ -132,13 +138,6 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
|
|||
x-text="server.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center pt-4">
|
||||
<x-status-badge dynamic>
|
||||
<span class="size-1.5 rounded-full"
|
||||
:class="server.ready ? 'bg-emerald-500' : 'bg-red-500'"></span>
|
||||
<span x-text="server.status"></span>
|
||||
</x-status-badge>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -154,8 +153,9 @@ class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutr
|
|||
<a :href="server.href" {{ wireNavigate() }}
|
||||
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
|
||||
<div :title="server.status" :aria-label="`Server status: ${server.status}`"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-neutral-50 text-neutral-500 dark:bg-white/[0.035] dark:text-fg-dim"
|
||||
:class="server.statusType === 'success' ? 'border-emerald-500/70' : server.statusType === 'warning' ? 'border-amber-500/70' : 'border-red-500/70'">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
|
|
@ -165,12 +165,8 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
|
|||
x-text="server.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<x-status-badge dynamic>
|
||||
<span class="size-1.5 rounded-full"
|
||||
:class="server.ready ? 'bg-emerald-500' : 'bg-red-500'"></span>
|
||||
<span x-text="server.status"></span>
|
||||
</x-status-badge>
|
||||
<div class="text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
|
||||
<span x-text="server.status"></span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -83,9 +83,12 @@ class="listbox-option justify-start! gap-2.5!" role="menuitem">
|
|||
helper="Non-root SSH users are experimental." />
|
||||
<x-forms.input type="number" id="port" label="Port" required />
|
||||
</div>
|
||||
<x-forms.checkbox id="is_build_server"
|
||||
<x-forms.select id="is_build_server"
|
||||
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
|
||||
label="Use as a dedicated build server" />
|
||||
label="Use as a dedicated build server">
|
||||
<option value="0">No</option>
|
||||
<option value="1">Yes</option>
|
||||
</x-forms.select>
|
||||
</x-forms.collapsible>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ class="h-8! rounded-lg! border-neutral-200! bg-white! py-0! pr-8! text-[12px]! s
|
|||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint"><span x-text="filteredItems.length"></span> <span x-text="filteredItems.length === 1 ? '{{ $singular }}' : '{{ $plural }}'"></span></span>
|
||||
<div class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
|
||||
<button type="button" x-on:click="setViewMode('grid')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
|
||||
<div class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
|
||||
<button type="button" x-on:click="setViewMode('grid')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div x-data="{ search: '' }" class="application-settings-form">
|
||||
<x-application.settings-section title="Backup schedules"
|
||||
description="Schedules currently writing backup data to this storage." flush>
|
||||
@if ($groupedBackups->count() === 0)
|
||||
@if ($groupedBackups->count() === 0 && $volumeBackups->count() === 0)
|
||||
<x-empty title="No backup schedules use this storage"
|
||||
description="Select this storage from a database or volume backup schedule to see it here."
|
||||
icon-name="storages" size="sm" />
|
||||
|
|
@ -18,7 +18,7 @@ class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! t
|
|||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Database</div>
|
||||
<div>Backup target</div>
|
||||
<div>Frequency</div>
|
||||
<div>Status</div>
|
||||
<div>Storage</div>
|
||||
|
|
@ -93,10 +93,13 @@ class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax
|
|||
<span class="text-neutral-500 dark:text-fg-dim">{{ $backup->frequency }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$backup->enabled ? 'success' : 'warning'" />
|
||||
<div class="flex items-center">
|
||||
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$backup->enabled ? 'success' : 'warning'" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.listbox id="selectedStorages.{{ $backup->id }}" :options="$storageOptions" />
|
||||
<x-forms.listbox id="selectedStorages.{{ $backup->id }}" :options="$storageOptions"
|
||||
portal />
|
||||
<button type="button" class="button shrink-0"
|
||||
wire:click="moveBackup({{ $backup->id }})">Move</button>
|
||||
<button type="button" class="button shrink-0 text-error"
|
||||
|
|
@ -108,6 +111,45 @@ class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax
|
|||
</div>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@foreach ($volumeBackups as $backup)
|
||||
@php
|
||||
$targetName = $backup->targetName();
|
||||
$targetType = $backup->targetType();
|
||||
$resource = $backup->targetResource();
|
||||
$resourceName = $resource?->human_name ?? $resource?->name;
|
||||
$storageOptions = $allStorages->map(fn ($s3) => [
|
||||
'value' => $s3->id,
|
||||
'label' => $s3->name.($s3->is_usable ? '' : ' (unusable)'),
|
||||
'disabled' => ! $s3->is_usable,
|
||||
])->values()->all();
|
||||
@endphp
|
||||
<div
|
||||
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
|
||||
x-show="search === '' || '{{ strtolower(addslashes($targetName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($targetType)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($resourceName ?? '')) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-black dark:text-fg">{{ $targetName }}</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">
|
||||
{{ $targetType }}@if ($resourceName) · {{ $resourceName }} @endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-neutral-500 dark:text-fg-dim">{{ $backup->frequency }}</div>
|
||||
<div class="flex items-center">
|
||||
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$backup->enabled ? 'success' : 'warning'" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.listbox id="selectedVolumeStorages.{{ $backup->id }}" :options="$storageOptions"
|
||||
portal />
|
||||
<button type="button" class="button shrink-0"
|
||||
wire:click="moveVolumeBackup({{ $backup->id }})">Move</button>
|
||||
<button type="button" class="button shrink-0 text-error"
|
||||
wire:click="disableVolumeS3({{ $backup->id }})"
|
||||
wire:confirm="Are you sure you want to disable S3 for this backup schedule?">
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
|
|
@ -70,9 +70,9 @@ class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-n
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'table'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
@ -81,7 +81,7 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" x-on:click="setViewMode('grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid'
|
||||
?
|
||||
'control-selected' :
|
||||
|
|
|
|||
|
|
@ -70,11 +70,16 @@
|
|||
themeAccents: @js($consoleThemeAccents),
|
||||
consoleTheme: 'system',
|
||||
themeOpen: false,
|
||||
get filteredTargets() {
|
||||
get filteredTargetGroups() {
|
||||
const query = this.targetSearch.trim().toLowerCase();
|
||||
return query
|
||||
const targets = query
|
||||
? this.targets.filter((target) => target.label.toLowerCase().includes(query))
|
||||
: this.targets;
|
||||
|
||||
return [
|
||||
{ type: 'server', label: 'Servers', targets: targets.filter((target) => target.type === 'server') },
|
||||
{ type: 'container', label: 'Containers', targets: targets.filter((target) => target.type === 'container') },
|
||||
].filter((group) => group.targets.length > 0);
|
||||
},
|
||||
init() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
|
|
@ -139,19 +144,27 @@ class="w-full rounded-md! border-white/[0.1]! bg-black/15! py-2! pr-3! pl-9! tex
|
|||
<div class="mt-1 text-xs text-white/45">Connect a reachable server and enable terminal access.</div>
|
||||
</div>
|
||||
@else
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
|
||||
</button>
|
||||
<template x-for="group in filteredTargetGroups" :key="group.type">
|
||||
<section class="not-last:mb-2">
|
||||
<div class="px-3 py-2 text-[10px] font-semibold tracking-wider text-white/40 uppercase"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="target in group.targets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<span class="rounded-full border border-white/10 px-2 py-0.5 text-[10px] text-white/40"
|
||||
x-text="target.type"></span>
|
||||
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<div x-show="filteredTargets.length === 0"
|
||||
<div x-show="filteredTargetGroups.length === 0"
|
||||
class="px-3 py-8 text-center text-sm text-white/50">
|
||||
No matching targets
|
||||
</div>
|
||||
|
|
@ -211,25 +224,33 @@ class="h-7! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2!
|
|||
</div>
|
||||
</div>
|
||||
<div class="terminal-target-list max-h-72 overflow-y-auto p-1">
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-cloak x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="layers" x-cloak x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<svg x-show="$wire.selected_uuid === target.value"
|
||||
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
|
||||
stroke-width="1.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<template x-for="group in filteredTargetGroups" :key="group.type">
|
||||
<section class="not-last:mb-1">
|
||||
<div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="target in group.targets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-cloak x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="layers" x-cloak x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
|
||||
x-text="target.type"></span>
|
||||
<svg x-show="$wire.selected_uuid === target.value"
|
||||
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
|
||||
stroke-width="1.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<div x-show="filteredTargets.length === 0"
|
||||
<div x-show="filteredTargetGroups.length === 0"
|
||||
class="px-2 py-5 text-center text-[11px] text-white/35">
|
||||
No matching targets
|
||||
</div>
|
||||
|
|
@ -281,18 +302,26 @@ class="h-8! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2!
|
|||
</div>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto p-1">
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
</button>
|
||||
<template x-for="group in filteredTargetGroups" :key="group.type">
|
||||
<section class="not-last:mb-1">
|
||||
<div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="target in group.targets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
|
||||
x-text="target.type"></span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<div x-show="filteredTargets.length === 0"
|
||||
<div x-show="filteredTargetGroups.length === 0"
|
||||
class="px-2 py-5 text-center text-[11px] text-white/35">
|
||||
No matching targets
|
||||
</div>
|
||||
|
|
|
|||
2
svgs/influxdb.svg
Normal file
2
svgs/influxdb.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>InfluxDB icon</title><path d="M23.775 14.443L21.482 4.5c-.128-.536-.621-1.093-1.178-1.243L9.868.043C9.739 0 9.589 0 9.418 0c-.45 0-.9.171-1.222.45L.718 7.414C.31 7.78.096 8.507.225 9.021l2.443 10.65c.128.536.621 1.093 1.178 1.243l9.772 3.043c.128.043.278.043.45.043.45 0 .9-.171 1.221-.45l7.993-7.436c.407-.428.622-1.114.493-1.671zM10.961 2.4l7.178 2.207c.279.086.279.214 0 .279l-3.771.857c-.279.086-.686-.043-.879-.257l-2.614-2.829c-.236-.236-.193-.343.086-.257zm4.478 12.857c.086.279-.107.45-.385.364l-7.736-2.4c-.279-.085-.343-.321-.129-.514L13.104 7.2c.214-.214.45-.129.514.15zM2.69 8.25L8.968 2.4c.214-.214.536-.171.75.021l3.15 3.408c.214.214.171.535-.022.75l-6.278 5.85c-.214.214-.536.171-.75-.022L2.668 9c-.214-.236-.193-.579.021-.75zm1.522 9.257l-1.65-7.286c-.086-.278.043-.342.235-.128l2.615 2.828c.214.215.278.622.214.9l-1.136 3.686c-.085.3-.214.3-.278 0zm9.193 4.286l-8.208-2.55a.555.555 0 01-.364-.686l1.372-4.414a.555.555 0 01.685-.364l8.207 2.528c.279.086.45.386.365.686l-1.372 4.414a.598.598 0 01-.685.386zm7.285-5.979l-5.485 5.1c-.215.215-.322.129-.236-.15l1.136-3.685c.085-.279.385-.579.685-.622l3.772-.857c.278-.107.321.021.128.214zm.6-1.114l-4.521 1.029c-.279.085-.579-.108-.643-.386l-1.929-8.357c-.085-.279.108-.579.386-.643l4.522-1.029c.278-.085.578.107.642.386l1.929 8.357c.064.322-.107.6-.386.643z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
svgs/stalwart.svg
Normal file
1
svgs/stalwart.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
7
svgs/termix.svg
Normal file
7
svgs/termix.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -22,7 +22,7 @@ services:
|
|||
volumes:
|
||||
- firefly-upload:/var/www/html/storage/upload
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080"]
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:8
|
||||
image: codeberg.org/forgejo/forgejo:15
|
||||
environment:
|
||||
- SERVICE_URL_FORGEJO_3000
|
||||
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}
|
||||
|
|
|
|||
34
templates/compose/influxdb.yaml
Normal file
34
templates/compose/influxdb.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# documentation: https://docs.influxdata.com/influxdb/
|
||||
# slogan: Open source time series database for metrics, events, and analytics.
|
||||
# category: databases
|
||||
# tags: influxdb,time series,metrics,iot,monitoring,telegraf
|
||||
# logo: svgs/influxdb.svg
|
||||
# port: 8086
|
||||
|
||||
services:
|
||||
influxdb:
|
||||
image: influxdb:2.7-alpine
|
||||
environment:
|
||||
- SERVICE_URL_INFLUXDB_8086
|
||||
- INFLUXDB_HTTP_BIND_ADDRESS=:8086
|
||||
- INFLUXDB_INIT_MODE=setup
|
||||
- INFLUXDB_INIT_USERNAME=$SERVICE_USER_INFLUXDB
|
||||
- INFLUXDB_INIT_PASSWORD=$SERVICE_PASSWORD_INFLUXDB
|
||||
- INFLUXDB_INIT_ORG=$SERVICE_ORG_INFLUXDB
|
||||
- INFLUXDB_INIT_BUCKET=$SERVICE_BUCKET_INFLUXDB
|
||||
- INFLUXDB_INIT_RETENTION=0
|
||||
- INFLUXDB_INIT_ADMIN_TOKEN=$SERVICE_PASSWORD_INFLUXDB_TOKEN
|
||||
volumes:
|
||||
- influxdb-data:/var/lib/influxdb2
|
||||
- influxdb-config:/etc/influxdb2
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- influx
|
||||
- ping
|
||||
- --host
|
||||
- http://127.0.0.1:8086
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
|
@ -40,6 +40,7 @@ services:
|
|||
content: |
|
||||
general_settings:
|
||||
proxy_batch_write_at: 60
|
||||
store_prompts_in_spend_logs: false
|
||||
|
||||
router_settings:
|
||||
redis_host: os.environ/REDIS_HOST
|
||||
|
|
@ -50,15 +51,15 @@ services:
|
|||
litellm_settings:
|
||||
set_verbose: false
|
||||
json_logs: true
|
||||
log_raw_request_response: true
|
||||
log_raw_request_response: false
|
||||
# turn_off_message_logging: false
|
||||
# redact_user_api_key_info: false
|
||||
service_callback: ["prometheus_system"]
|
||||
drop_params: true
|
||||
# max_budget: 100
|
||||
# budget_duration: 30d
|
||||
num_retries: 3
|
||||
request_timeout: 600
|
||||
num_retries: 1
|
||||
request_timeout: 120
|
||||
telemetry: false
|
||||
cache: true
|
||||
cache_params:
|
||||
|
|
@ -131,17 +132,21 @@ services:
|
|||
- CMD
|
||||
- python
|
||||
- "-c"
|
||||
- "import requests as r;r.get('http://127.0.0.1:4000/health/liveliness').raise_for_status()"
|
||||
interval: 5s
|
||||
- "import urllib.request;urllib.request.urlopen('http://127.0.0.1:4000/health/liveliness',timeout=5)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
command:
|
||||
- "--config"
|
||||
- /app/config.yaml
|
||||
- "--port"
|
||||
- "4000"
|
||||
- "--num_workers"
|
||||
- "8"
|
||||
- "2"
|
||||
- "--run_gunicorn"
|
||||
- "--max_requests_before_restart"
|
||||
- "1000"
|
||||
postgres:
|
||||
image: "postgres:16-alpine"
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -47,10 +47,15 @@ x-shared-env: &shared-api-env
|
|||
PHP_MAX_EXECUTION_TIME: "600"
|
||||
PHP_UPLOAD_MAX_FILESIZE: "64M"
|
||||
PHP_POST_MAX_SIZE: "64M"
|
||||
# Frontend settings
|
||||
FRONT_URL: ${SERVICE_URL_NGINX}
|
||||
FRONT_API_SECRET: ${SERVICE_PASSWORD_FRONTAPISECRET}
|
||||
PUBLIC_UPLOADS_RATE_LIMIT_PER_MINUTE: ${PUBLIC_UPLOADS_RATE_LIMIT_PER_MINUTE:-30}
|
||||
PUBLIC_UPLOADS_RATE_LIMIT_PER_HOUR: ${PUBLIC_UPLOADS_RATE_LIMIT_PER_HOUR:-300}
|
||||
|
||||
services:
|
||||
opnform-api:
|
||||
image: jhumanj/opnform-api:1.12.1
|
||||
image: jhumanj/opnform-api:2.2.4
|
||||
volumes:
|
||||
- api-storage:/usr/share/nginx/html/storage
|
||||
environment:
|
||||
|
|
@ -58,7 +63,7 @@ services:
|
|||
<<: *shared-api-env
|
||||
JWT_TTL: ${JWT_TTL:-1440}
|
||||
JWT_SECRET: ${SERVICE_PASSWORD_JWTSECRET}
|
||||
JWT_SKIP_IP_UA_VALIDATION: ${JWT_SKIP_IP_UA_VALIDATION:-true}
|
||||
JWT_SKIP_IP_UA_VALIDATION: ${JWT_SKIP_IP_UA_VALIDATION:-false}
|
||||
H_CAPTCHA_SITE_KEY: ${H_CAPTCHA_SITE_KEY}
|
||||
H_CAPTCHA_SECRET_KEY: ${H_CAPTCHA_SECRET_KEY}
|
||||
RE_CAPTCHA_SITE_KEY: ${RE_CAPTCHA_SITE_KEY}
|
||||
|
|
@ -77,7 +82,7 @@ services:
|
|||
start_period: 60s
|
||||
|
||||
api-worker:
|
||||
image: jhumanj/opnform-api:1.12.1
|
||||
image: jhumanj/opnform-api:2.2.4
|
||||
volumes:
|
||||
- api-storage:/usr/share/nginx/html/storage
|
||||
environment:
|
||||
|
|
@ -98,7 +103,7 @@ services:
|
|||
start_period: 30s
|
||||
|
||||
api-scheduler:
|
||||
image: jhumanj/opnform-api:1.12.1
|
||||
image: jhumanj/opnform-api:2.2.4
|
||||
volumes:
|
||||
- api-storage:/usr/share/nginx/html/storage
|
||||
environment:
|
||||
|
|
@ -122,7 +127,7 @@ services:
|
|||
start_period: 70s # Allow time for first scheduled run and cache write
|
||||
|
||||
opnform-ui:
|
||||
image: jhumanj/opnform-client:1.12.1
|
||||
image: jhumanj/opnform-client:2.2.4
|
||||
environment:
|
||||
- NUXT_PUBLIC_APP_URL=/
|
||||
- NUXT_PUBLIC_API_BASE=/api
|
||||
|
|
@ -130,6 +135,8 @@ services:
|
|||
- NUXT_PUBLIC_ENV=production
|
||||
- NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=${H_CAPTCHA_SITE_KEY}
|
||||
- NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY=${RE_CAPTCHA_SITE_KEY}
|
||||
- NUXT_API_SECRET=${SERVICE_PASSWORD_FRONTAPISECRET}
|
||||
- NUXT_PUBLIC_LICENSE_API_ENDPOINT=${NUXT_PUBLIC_LICENSE_API_ENDPOINT:-https://api.opnform.com}
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "wget --spider -q http://opnform-ui:3000/login || exit 1"]
|
||||
|
|
@ -142,9 +149,9 @@ services:
|
|||
condition: service_healthy
|
||||
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
image: postgres:18
|
||||
volumes:
|
||||
- opnform-postgresql-data:/var/lib/postgresql/data
|
||||
- opnform-postgresql-data:/var/lib/postgresql
|
||||
environment:
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
|
||||
|
|
@ -171,7 +178,7 @@ services:
|
|||
# The nginx reverse proxy.
|
||||
# used for reverse proxying the API service and Web service.
|
||||
nginx:
|
||||
image: nginx:1.29.2
|
||||
image: nginx:1.31.3
|
||||
environment:
|
||||
- SERVICE_URL_NGINX
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
seaweedfs-master:
|
||||
image: chrislusf/seaweedfs:4.13
|
||||
image: chrislusf/seaweedfs:4.41
|
||||
environment:
|
||||
- SERVICE_URL_S3_8333
|
||||
- AWS_ACCESS_KEY_ID=${SERVICE_USER_S3}
|
||||
|
|
@ -61,7 +61,7 @@ services:
|
|||
retries: 10
|
||||
|
||||
seaweedfs-admin:
|
||||
image: chrislusf/seaweedfs:4.13
|
||||
image: chrislusf/seaweedfs:4.41
|
||||
environment:
|
||||
- SERVICE_URL_ADMIN_23646
|
||||
- SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN}
|
||||
|
|
|
|||
|
|
@ -7,26 +7,42 @@
|
|||
|
||||
services:
|
||||
sparkyfitness-frontend:
|
||||
image: 'codewithcj/sparkyfitness:v0.15.7.3' # Released on Oct 18, 2025
|
||||
image: 'codewithcj/sparkyfitness:v1.6.1'
|
||||
environment:
|
||||
- SERVICE_URL_SPARKYFITNESS_80
|
||||
- 'SPARKY_FITNESS_FRONTEND_URL=${SERVICE_URL_SPARKYFITNESS_80}'
|
||||
- SPARKY_FITNESS_SERVER_HOST=sparkyfitness-server
|
||||
- SPARKY_FITNESS_SERVER_PORT=3010
|
||||
depends_on:
|
||||
- sparkyfitness-server
|
||||
|
||||
sparkyfitness-server:
|
||||
image: 'codewithcj/sparkyfitness_server:v0.15.7.3' # Released on Oct 18, 2025
|
||||
image: 'codewithcj/sparkyfitness_server:v1.6.1'
|
||||
environment:
|
||||
- 'SPARKY_FITNESS_LOG_LEVEL=${SPARKY_FITNESS_LOG_LEVEL:-info}'
|
||||
- 'NODE_ENV=${NODE_ENV:-production}'
|
||||
- 'TZ=${TZ:-Etc/UTC}'
|
||||
# Database — superuser (used for migrations only)
|
||||
- 'SPARKY_FITNESS_DB_USER=${SERVICE_USER_POSTGRES}'
|
||||
- SPARKY_FITNESS_DB_HOST=sparkyfitness-db
|
||||
- 'SPARKY_FITNESS_DB_NAME=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
|
||||
- 'SPARKY_FITNESS_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
|
||||
- 'SPARKY_FITNESS_DB_PORT=${SPARKY_FITNESS_DB_PORT:-5432}'
|
||||
# Database — application user (unprivileged, used at runtime)
|
||||
- 'SPARKY_FITNESS_APP_DB_USER=${SPARKY_FITNESS_APP_DB_USER:-sparkyapp}'
|
||||
- 'SPARKY_FITNESS_APP_DB_PASSWORD=${SERVICE_PASSWORD_64_APPDBPASSWORD}'
|
||||
# Secrets
|
||||
- 'SPARKY_FITNESS_API_ENCRYPTION_KEY=${SERVICE_PASSWORD_64_SERVERAPIENCRYPTIONKEY}'
|
||||
- 'JWT_SECRET=${SERVICE_PASSWORD_64_SERVERJWTSECRET}'
|
||||
- 'BETTER_AUTH_SECRET=${SERVICE_PASSWORD_64_BETTERAUTHSECRET}'
|
||||
# CORS / trusted origins
|
||||
- 'SPARKY_FITNESS_FRONTEND_URL=${SERVICE_URL_SPARKYFITNESS_80}'
|
||||
- 'ALLOW_PRIVATE_NETWORK_CORS=${ALLOW_PRIVATE_NETWORK_CORS:-false}'
|
||||
- 'SPARKY_FITNESS_EXTRA_TRUSTED_ORIGINS=${SPARKY_FITNESS_EXTRA_TRUSTED_ORIGINS:-}'
|
||||
# Auth / signup
|
||||
- 'SPARKY_FITNESS_DISABLE_SIGNUP=${SPARKY_FITNESS_DISABLE_SIGNUP:-false}'
|
||||
- 'SPARKY_FITNESS_FORCE_EMAIL_LOGIN=${SPARKY_FITNESS_FORCE_EMAIL_LOGIN:-true}'
|
||||
- 'SPARKY_FITNESS_ADMIN_EMAIL=${SPARKY_FITNESS_ADMIN_EMAIL:-admin@example.com}'
|
||||
# Email (optional)
|
||||
- 'SPARKY_FITNESS_EMAIL_HOST=${SPARKY_FITNESS_EMAIL_HOST:-smtp.gmail.com}'
|
||||
- 'SPARKY_FITNESS_EMAIL_PORT=${SPARKY_FITNESS_EMAIL_PORT:-587}'
|
||||
- 'SPARKY_FITNESS_EMAIL_SECURE=${SPARKY_FITNESS_EMAIL_SECURE:-false}'
|
||||
|
|
@ -40,7 +56,7 @@ services:
|
|||
- 'sparkyfitness-server-uploads:/app/SparkyFitnessServer/uploads'
|
||||
|
||||
sparkyfitness-db:
|
||||
image: 'postgres:15-alpine'
|
||||
image: 'postgres:18-alpine'
|
||||
environment:
|
||||
- 'POSTGRES_DB=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
|
||||
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
|
||||
|
|
@ -52,5 +68,5 @@ services:
|
|||
timeout: 20s
|
||||
retries: 10
|
||||
volumes:
|
||||
- 'sparkyfitness-db-postgresql:/var/lib/postgresql/data'
|
||||
- 'sparkyfitness-db-postgresql:/var/lib/postgresql'
|
||||
|
||||
|
|
|
|||
32
templates/compose/stalwart.yaml
Normal file
32
templates/compose/stalwart.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# documentation: https://stalw.art/docs/
|
||||
# slogan: Modern mail server platform
|
||||
# category: Mail
|
||||
# tags: mail,smtp,imap,pop3,jmap,email,sieve,messaging
|
||||
# logo: svgs/stalwart.svg
|
||||
# port: 8080
|
||||
|
||||
services:
|
||||
stalwart:
|
||||
image: 'stalwartlabs/stalwart:v0.16.13'
|
||||
environment:
|
||||
- SERVICE_URL_STALWART_8080
|
||||
expose:
|
||||
- '8080'
|
||||
ports:
|
||||
- '25:25'
|
||||
- '465:465'
|
||||
- '587:587'
|
||||
- '993:993'
|
||||
- '995:995'
|
||||
- '143:143'
|
||||
- '110:110'
|
||||
- '4190:4190'
|
||||
volumes:
|
||||
- 'stalwart-mail:/etc/stalwart'
|
||||
- 'stalwart-data:/var/lib/stalwart'
|
||||
- '/etc/localtime:/etc/localtime:ro'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
23
templates/compose/termix.yaml
Normal file
23
templates/compose/termix.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# documentation: https://docs.termix.site/install/server/docker
|
||||
# slogan: All-in-One SSH & Remote Desktop management app.
|
||||
# category: developer-tools
|
||||
# tags: ssh,terminal,remote-desktop,rdp,vnc,server-management,remote-access
|
||||
# logo: svgs/termix.svg
|
||||
# port: 8080
|
||||
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:2.6.1
|
||||
environment:
|
||||
- SERVICE_URL_TERMIX_8080
|
||||
- PORT=8080
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- GUACD_HOST=guacd
|
||||
- GUACD_RECORDING_PATH=/termix-data/session_recordings/guacamole
|
||||
depends_on:
|
||||
- guacd
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.6.0
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -14,12 +14,18 @@
|
|||
->toContain("['value' => 'custom', 'label' => 'Custom'")
|
||||
->toContain('type="color"')
|
||||
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
|
||||
->toContain("localStorage.setItem('themeColor', this.themeColor)")
|
||||
->toContain('requestAnimationFrame(() =>')
|
||||
->toContain('@input="previewThemeColor($event.target.value)"')
|
||||
->toContain('@change="saveThemeColor($event.target.value)"')
|
||||
->toContain("localStorage.setItem('themeColor', color)")
|
||||
->toContain("this.theme === 'custom'")
|
||||
->and($accountMenu)
|
||||
->toContain("['value' => 'custom', 'label' => 'Custom']")
|
||||
->toContain('aria-label="Custom theme color"')
|
||||
->toContain("localStorage.setItem('themeColor', this.themeColor)")
|
||||
->toContain('requestAnimationFrame(() =>')
|
||||
->toContain('@input="previewThemeColor($event.target.value)"')
|
||||
->toContain('@change="saveThemeColor($event.target.value)"')
|
||||
->toContain("localStorage.setItem('themeColor', color)")
|
||||
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
|
||||
->and($layout)
|
||||
->toContain("t === 'custom'")
|
||||
|
|
|
|||
|
|
@ -53,6 +53,17 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment
|
|||
], $overrides));
|
||||
}
|
||||
|
||||
it('shows a visible yellow inset focus state on deployment rows', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/dashboard/active-deployments.blade.php'));
|
||||
|
||||
expect(substr_count($view, 'focus-visible:bg-warning/10'))->toBe(4)
|
||||
->and(substr_count($view, 'focus-visible:ring-1'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-inset'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-warning'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-offset-0'))->toBe(2)
|
||||
->and(substr_count($view, 'dark:focus-visible:bg-warning/10'))->toBe(2);
|
||||
});
|
||||
|
||||
it('hides the deployments section when there is nothing to show', function () {
|
||||
Livewire::test(ActiveDeployments::class)
|
||||
->assertDontSee('Active and recent deployment activity')
|
||||
|
|
|
|||
86
tests/Feature/DashboardServerMetricsChartTest.php
Normal file
86
tests/Feature/DashboardServerMetricsChartTest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$this->team = $team;
|
||||
});
|
||||
|
||||
it('renders a metrics chart only for servers with metrics enabled', function () {
|
||||
$enabledServer = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$enabledServer->settings->update(['is_metrics_enabled' => true]);
|
||||
|
||||
$disabledServer = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$disabledServer->settings->update(['is_metrics_enabled' => false]);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSeeHtml("dashboard-server-metrics-{$enabledServer->uuid}")
|
||||
->assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}");
|
||||
});
|
||||
|
||||
it('configures the dashboard chart as a ten minute cpu and memory sparkline with hover details', function () {
|
||||
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Dashboard/ServerMetricsChart.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('$this->server->getCpuMetrics(10)')
|
||||
->toContain('$this->server->getMemoryMetrics(10)');
|
||||
expect($chart)
|
||||
->toContain('new ApexCharts')
|
||||
->toContain('sparkline: { enabled: true }')
|
||||
->toContain('absolute right-0 bottom-0 h-2/3 w-full')
|
||||
->toContain('[&_.apexcharts-svg]:overflow-hidden')
|
||||
->not->toContain('w-full overflow-hidden rounded-b-xl')
|
||||
->toContain('CPU:')
|
||||
->toContain('Memory:')
|
||||
->toContain('formatTimestamp(timestamp)')
|
||||
->toContain("min: 0,\n max: 100,")
|
||||
->toContain('labels: { show: false }');
|
||||
});
|
||||
|
||||
it('refreshes dashboard metrics every minute while visible and after returning from the background', function () {
|
||||
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
|
||||
|
||||
expect($chart)
|
||||
->toContain('window.setInterval')
|
||||
->toContain('60000')
|
||||
->toContain('document.hidden')
|
||||
->toContain("document.addEventListener('visibilitychange'")
|
||||
->toContain('Date.now() - this.hiddenAt >= 60000')
|
||||
->toContain('$wire.loadData()')
|
||||
->toContain('window.clearInterval')
|
||||
->toContain("document.removeEventListener('visibilitychange'");
|
||||
});
|
||||
|
||||
it('keeps the status badge only on server cards without metrics', function () {
|
||||
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
|
||||
|
||||
expect($dashboard)->toContain('@unless ($server->isMetricsEnabled())');
|
||||
});
|
||||
|
|
@ -11,11 +11,15 @@
|
|||
|
||||
test('deployment logs use light surfaces in light mode and dark surfaces in dark mode', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('bg-white text-neutral-800')
|
||||
->toContain('dark:bg-[#0d0d0d] dark:text-neutral-100')
|
||||
->toContain('border-neutral-200 shadow-sm dark:border-neutral-800')
|
||||
->toContain('dark:bg-log dark:text-neutral-100')
|
||||
->toContain('border-neutral-200 shadow-sm dark:border-coolgray-200')
|
||||
->toContain('border-neutral-200! bg-white!')
|
||||
->toContain('dark:border-white/[0.08]! dark:bg-white/[0.05]!');
|
||||
->toContain('dark:border-white/[0.08]! dark:bg-white/[0.05]!')
|
||||
->and($styles)
|
||||
->toMatch('/\.logs-viewer\s*\{[^}]*background:\s*#fff;[^}]*color:\s*#262626;/s')
|
||||
->toMatch('/\.dark \.logs-viewer\s*\{[^}]*background:\s*var\(--color-log\);/s');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@
|
|||
->toContain('.logs-viewer-actions')
|
||||
->toContain('.logs-viewer-deployment-actions')
|
||||
->toContain('.logs-settings-section')
|
||||
->toContain('padding: 0.5rem 0.75rem 2rem;')
|
||||
->toContain('padding: 0.5rem 1rem 2rem;')
|
||||
->toContain('flex-direction: column')
|
||||
->toContain('@media (min-width: 640px)');
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,31 @@
|
|||
expect($baseLayout)->toContain('<x-icon-tooltip />')
|
||||
->and($tooltip)
|
||||
->toContain("closest('button, a, [data-tooltip]')")
|
||||
->toContain("target.querySelector('svg')")
|
||||
->toContain("target.matches('[data-tooltip], .icon-button')")
|
||||
->toContain("target.hasAttribute('aria-label')")
|
||||
->toContain('target.childElementCount === 1')
|
||||
->toContain("target.firstElementChild?.matches('svg')")
|
||||
->not->toContain("target.querySelector('svg')")
|
||||
->toContain("target.matches('[data-icon-tooltip-ignore]')")
|
||||
->toContain("target.removeAttribute('title')")
|
||||
->toContain('role="tooltip"')
|
||||
->toContain('aria-label')
|
||||
->toContain('fixed z-[100]');
|
||||
});
|
||||
|
||||
it('does not reposition an already active tooltip during nested mouseover events', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('if (target === this.activeTarget && this.visible) return;');
|
||||
});
|
||||
|
||||
it('keeps a tooltip hidden until its measured position is applied', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('positioned: false')
|
||||
->toContain('this.positioned = false;')
|
||||
->toContain('this.$nextTick(() => this.positioned = true);')
|
||||
->toContain("positioned ? 'visible' : 'invisible'");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
expect($view)
|
||||
->not->toContain('Create mode (server-rendered path)')
|
||||
->not->toContain('!$wire.isCreateMode')
|
||||
->toContain("<!-- Command palette -->\n <div x-show=\"modalOpen\"")
|
||||
->toContain("<!-- Command palette -->\n <div x-cloak")
|
||||
->not->toContain("<!-- Command palette -->\n <template x-teleport=\"body\">")
|
||||
->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length")
|
||||
->toContain('x-for="(result, index) in searchResults"')
|
||||
|
|
@ -32,3 +32,29 @@
|
|||
expect($view)
|
||||
->toContain('filter(item => item.offsetParent !== null)');
|
||||
});
|
||||
|
||||
it('opens the command palette without changing page scrollbar visibility', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain("document.body.style.overflow = value ? 'hidden' : ''")
|
||||
->not->toContain("document.body.style.overflow = ''");
|
||||
});
|
||||
|
||||
it('animates the command palette with tw animate utilities', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div x-show="modalOpen" @click="closeModal()"')
|
||||
->toContain('x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"')
|
||||
->not->toContain('<div x-show="modalOpen" x-cloak\n class="fixed inset-0');
|
||||
});
|
||||
|
||||
it('closes the client-side command palette without a Livewire request', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('$wire.closeSearchModal()')
|
||||
->not->toContain('closeTimer');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
expect($utilities)
|
||||
->toContain('@utility button-highlighted')
|
||||
->toContain('@apply border-2 text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;')
|
||||
->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;')
|
||||
->and($appStyles)
|
||||
->toContain('button[isHighlighted]:not(:disabled)')
|
||||
->toContain('@apply button-highlighted;')
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
<?php
|
||||
|
||||
test('loading indicators use yellow throughout dark mode', function () {
|
||||
test('loading indicators use coollabs purple throughout dark mode', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
|
||||
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
|
||||
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->isFile() && $file->getExtension() === 'php')
|
||||
->map(fn (SplFileInfo $file): string => file_get_contents($file->getPathname()))
|
||||
->implode("\n");
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility loading-indicator')
|
||||
->toContain('@apply text-coollabs dark:text-warning;')
|
||||
->toContain('@apply text-coollabs dark:text-coollabs;')
|
||||
->and($loading)->toContain('loading-indicator')
|
||||
->and($pageLoading)->toContain('loading-indicator')
|
||||
->and($appCss)->toContain('.dark .animate-spin')
|
||||
->toContain('color: var(--color-warning) !important;')
|
||||
->toContain('color: var(--color-coollabs) !important;')
|
||||
->toContain('.dark #nprogress .bar')
|
||||
->toContain('background: var(--color-warning) !important;')
|
||||
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/');
|
||||
->toContain('background: var(--color-coollabs) !important;')
|
||||
->not->toContain('color: var(--color-warning) !important;');
|
||||
});
|
||||
|
||||
test('livewire navigation progress bar uses coollabs purple', function () {
|
||||
|
|
|
|||
8
tests/Feature/NavbarFocusRingTest.php
Normal file
8
tests/Feature/NavbarFocusRingTest.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
it('keeps horizontal space around sidebar items for focus rings', function () {
|
||||
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->toContain('class="-mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2 scrollbar"');
|
||||
});
|
||||
|
|
@ -155,6 +155,19 @@
|
|||
->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s');
|
||||
});
|
||||
|
||||
it('groups global terminal targets by type with labelled sections', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('get filteredTargetGroups()')
|
||||
->toContain("label: 'Servers'")
|
||||
->toContain("label: 'Containers'")
|
||||
->toContain('x-for="group in filteredTargetGroups"')
|
||||
->toContain('x-text="group.label"')
|
||||
->toContain('x-for="target in group.targets"')
|
||||
->not->toContain('x-for="target in filteredTargets"');
|
||||
});
|
||||
|
||||
it('uses the redesigned terminal canvas and controls on resource terminal pages', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
|
|
|
|||
|
|
@ -177,19 +177,20 @@
|
|||
->not->toContain("'label' => 'Runtime'")
|
||||
->and($sidebar)
|
||||
->toContain("'label' => 'Terminal'")
|
||||
->toContain("'label' => 'Deployment'")
|
||||
->toContain("'label' => 'Runtime'")
|
||||
->toContain("'Logs' => ['Deployment', 'Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
|
||||
->toContain("'label' => 'Deployment Logs'")
|
||||
->toContain("'label' => 'Runtime Logs'")
|
||||
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
|
||||
});
|
||||
|
||||
it('groups application automation pages separately from build and deploy', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments']")
|
||||
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
|
||||
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
|
||||
->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
|
||||
});
|
||||
|
||||
it('centers the rollback image loading state across the card', function () {
|
||||
|
|
@ -291,7 +292,7 @@
|
|||
it('uses a distinct runtime log icon in the sidebar', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)->toContain("'Runtime' => 'unordered-list'");
|
||||
expect($sidebar)->toContain("'Runtime Logs' => 'unordered-list'");
|
||||
});
|
||||
|
||||
it('shows deployment history above the selected deployment logs', function () {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,25 @@
|
|||
->toContain('Enter</kbd>');
|
||||
});
|
||||
|
||||
test('unsaved bar keyboard shortcut remains visible in light mode', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('border-coollabs/20 bg-coollabs/10')
|
||||
->toContain('text-coollabs-200')
|
||||
->toContain('dark:border-white/20 dark:bg-white/10 dark:text-white/75');
|
||||
});
|
||||
|
||||
test('unsaved bar uses a light surface in light mode', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('border-neutral-200 bg-white')
|
||||
->toContain('text-neutral-800 dark:text-fg')
|
||||
->toContain('bg-neutral-100')
|
||||
->toContain('dark:bg-white/[0.07]');
|
||||
});
|
||||
|
||||
test('unsaved bar stays above floating notifications so save actions remain accessible', function () {
|
||||
$unsavedBar = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
$popup = file_get_contents(resource_path('views/components/popup-small.blade.php'));
|
||||
|
|
|
|||
|
|
@ -18,3 +18,42 @@
|
|||
->not->toContain('new-server-token-')
|
||||
->not->toContain('tokenProviderName');
|
||||
});
|
||||
|
||||
test('server selection separates existing servers from cloud provisioning', function (string $viewPath) {
|
||||
$view = file_get_contents(resource_path($viewPath));
|
||||
|
||||
expect($view)
|
||||
->toContain('Add a server')
|
||||
->toContain('IP address or domain')
|
||||
->toContain('Provision a server')
|
||||
->and(strpos($view, 'Add a server'))->toBeLessThan(strpos($view, 'Provision a server'));
|
||||
})->with([
|
||||
'new server page' => 'views/livewire/server/create.blade.php',
|
||||
'onboarding' => 'views/livewire/boarding/index.blade.php',
|
||||
]);
|
||||
|
||||
test('new server sections have vertical spacing', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
|
||||
|
||||
expect($view)->toContain('<div class="application-settings-form flex flex-col gap-6">');
|
||||
});
|
||||
|
||||
test('server selection uses the provider logos', function () {
|
||||
$newServerView = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
|
||||
$onboardingView = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($newServerView)
|
||||
->toContain('src="https://www.vultr.com/media/logo_ondark.svg"')
|
||||
->toContain("src=\"{{ asset('svgs/hetzner.svg') }}\"")
|
||||
->and($onboardingView)
|
||||
->toContain('src="https://www.vultr.com/media/logo_ondark.svg"')
|
||||
->toContain("src=\"{{ asset('svgs/hetzner.svg') }}\"");
|
||||
});
|
||||
|
||||
test('new server cards do not show method badges', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain("\n Manual\n")
|
||||
->not->toContain("\n Provider\n");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
expect($view)
|
||||
->toContain('class="flex items-end gap-3"')
|
||||
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
|
||||
->toContain('<x-forms.select id="is_build_server"')
|
||||
->toContain('label="Use as a dedicated build server"')
|
||||
->toContain('<option value="0">No</option>')
|
||||
->toContain('<option value="1">Yes</option>')
|
||||
->not->toContain('<x-forms.checkbox id="is_build_server"')
|
||||
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,21 @@
|
|||
resource_path('views/livewire/project/resource/index.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
resource_path('views/livewire/tags/show.blade.php'),
|
||||
resource_path('views/livewire/shared/list-search-controls.blade.php'),
|
||||
resource_path('views/components/shared-variables/view-controls.blade.php'),
|
||||
])->map(fn (string $path): string => file_get_contents($path));
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($views->every(fn (string $view): bool => str_contains($view, "viewMode === 'table'")
|
||||
expect($views->every(fn (string $view): bool => (str_contains($view, "viewMode === 'table'") || str_contains($view, "viewMode === 'list'"))
|
||||
&& str_contains($view, "viewMode === 'grid'")
|
||||
&& str_contains($view, 'control-selected')))
|
||||
->toBeTrue()
|
||||
->and($views->every(fn (string $view): bool => str_contains($view, 'flex h-9')
|
||||
&& str_contains($view, 'size-7.5')))->toBeTrue()
|
||||
->and($views->implode("\n"))->not->toContain('dark:bg-warning/15 dark:text-warning')
|
||||
->and($utilities)
|
||||
->toContain('@utility control-selected')
|
||||
->toContain('@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;');
|
||||
->toContain('@apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white;');
|
||||
});
|
||||
|
||||
test('server index does not expose server IP addresses', function () {
|
||||
|
|
|
|||
34
tests/Feature/ServerStatusIndicatorDesignTest.php
Normal file
34
tests/Feature/ServerStatusIndicatorDesignTest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
test('server cards use icon borders instead of ready badges', function () {
|
||||
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
|
||||
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
|
||||
|
||||
expect($dashboard)
|
||||
->not->toContain('<x-status-badge :status="$serverStatus"')
|
||||
->toContain("'border-emerald-500/70' => \$serverStatusType === 'success'")
|
||||
->toContain("'border-amber-500/70' => \$serverStatusType === 'warning'")
|
||||
->toContain("'border-red-500/70' => \$serverStatusType === 'error'")
|
||||
->toContain('title="{{ $serverStatus }}"')
|
||||
->toContain('aria-label="Server status: {{ $serverStatus }}"');
|
||||
|
||||
expect(substr_count($serverIndex, '<x-status-badge'))->toBe(1)
|
||||
->and($serverIndex)
|
||||
->toContain("\$isReady => 'success'")
|
||||
->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'")
|
||||
->toContain("default => 'warning'")
|
||||
->toContain("server.statusType === 'success' ? 'border-emerald-500/70'")
|
||||
->toContain("server.statusType === 'warning' ? 'border-amber-500/70'")
|
||||
->toContain("'border-red-500/70'")
|
||||
->toContain(':title="server.status"')
|
||||
->toContain(':aria-label="`Server status: ${server.status}`"');
|
||||
});
|
||||
|
||||
test('server table keeps status text without a badge', function () {
|
||||
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
|
||||
|
||||
expect($serverIndex)
|
||||
->toContain('<span x-text="server.status"></span>')
|
||||
->toContain('text-[11px] font-medium')
|
||||
->not->toContain('<x-status-badge dynamic>');
|
||||
});
|
||||
|
|
@ -10,11 +10,11 @@
|
|||
->and($databaseHeading)->not->toContain('<x-resource-heading-tabs')
|
||||
->and($serviceConfiguration)
|
||||
->toContain("['label' => 'Backups'")
|
||||
->toContain("['label' => 'Runtime'")
|
||||
->toContain("['label' => 'Runtime Logs'")
|
||||
->toContain("['label' => 'Terminal'")
|
||||
->and($databaseSidebar)
|
||||
->toContain("['label' => 'Backups'")
|
||||
->toContain("['label' => 'Runtime'")
|
||||
->toContain("['label' => 'Runtime Logs'")
|
||||
->toContain("['label' => 'Terminal'");
|
||||
});
|
||||
|
||||
|
|
@ -41,21 +41,38 @@
|
|||
->and($database)->toContain('id="database-desktop-actions"');
|
||||
});
|
||||
|
||||
it('keeps database and service sidebar sections in the application sequence', function () {
|
||||
it('groups database and service navigation by user workflow', function () {
|
||||
$database = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
$serviceSidebars = [
|
||||
file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php')),
|
||||
file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')),
|
||||
];
|
||||
|
||||
expect($database)
|
||||
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']")
|
||||
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
|
||||
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck']")
|
||||
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics']")
|
||||
->toContain("'Deploy' => ['Servers']")
|
||||
->toContain("'Automation' => ['Webhooks', 'Backups', 'Import Backup']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone']");
|
||||
|
||||
expect($service)
|
||||
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks']")
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
|
||||
foreach ($serviceSidebars as $serviceSidebar) {
|
||||
expect($serviceSidebar)
|
||||
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage']")
|
||||
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
|
||||
}
|
||||
});
|
||||
|
||||
it('groups application navigation by user workflow', function () {
|
||||
$application = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($application)
|
||||
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
|
||||
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
|
||||
->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone']");
|
||||
});
|
||||
|
||||
it('shows the database sidebar on backup pages', function () {
|
||||
|
|
@ -155,6 +172,6 @@
|
|||
->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)")
|
||||
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
|
||||
->and($sidebar)
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
|
||||
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
|
||||
->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
use App\Livewire\Storage\Resources as StorageResources;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
|
@ -75,6 +77,57 @@
|
|||
});
|
||||
|
||||
describe('Storage/Resources team-scoped backup access', function () {
|
||||
test('lists and manages volume backup schedules using this storage', function () {
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => 'minio-volume-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => 999,
|
||||
'resource_type' => 'App\\Models\\Application',
|
||||
]);
|
||||
$backup = ScheduledVolumeBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'backupable_type' => $volume->getMorphClass(),
|
||||
'backupable_id' => $volume->id,
|
||||
'team_id' => $this->teamA->id,
|
||||
's3_storage_id' => $this->storageA->id,
|
||||
'frequency' => 'daily',
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
]);
|
||||
$destination = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'volume-backup-destination',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-c',
|
||||
'secret' => 'secret-c',
|
||||
'bucket' => 'bucket-c',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamA->id,
|
||||
'is_usable' => true,
|
||||
]));
|
||||
|
||||
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->assertSee('minio-volume-data')
|
||||
->assertSee('Volume')
|
||||
->assertSeeHtml('class="listbox-trigger"')
|
||||
->assertSeeHtml('x-teleport="body"')
|
||||
->assertSeeHtml('requestAnimationFrame')
|
||||
->assertSeeHtml('-panel"')
|
||||
->assertSeeHtml("visibility: positioned ? 'visible' : 'hidden'")
|
||||
->assertSeeHtml('positionPanel($el)')
|
||||
->assertSeeHtml('class="flex items-center"')
|
||||
->set("selectedVolumeStorages.{$backup->id}", $destination->id)
|
||||
->call('moveVolumeBackup', $backup->id);
|
||||
|
||||
expect($backup->refresh()->s3_storage_id)->toBe($destination->id);
|
||||
|
||||
Livewire::test(StorageResources::class, ['storage' => $destination])
|
||||
->call('disableVolumeS3', $backup->id);
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalse()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('disableS3 on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupB->id))
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@
|
|||
->toContain('{{ $userEmail }}');
|
||||
});
|
||||
|
||||
it('animates the dropdown panel when the user menu opens', function () {
|
||||
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
$stylesheet = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($menu)
|
||||
->toContain('animate-in fade-in zoom-in-95 duration-150')
|
||||
->toContain("'origin-bottom-left' => \$sidebar")
|
||||
->toContain("'origin-top-right' => ! \$sidebar");
|
||||
|
||||
expect($stylesheet)->toContain('@import "tw-animate-css";');
|
||||
});
|
||||
|
||||
it('changes appearance from a submenu instead of navigating to a separate page', function () {
|
||||
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
|
||||
|
|
|
|||
12
tests/Unit/DarkModeFocusRingTest.php
Normal file
12
tests/Unit/DarkModeFocusRingTest.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
it('uses a thin warning yellow focus ring in dark mode', function () {
|
||||
$appStyles = file_get_contents(dirname(__DIR__, 2).'/resources/css/app.css');
|
||||
$v5Styles = file_get_contents(dirname(__DIR__, 2).'/resources/css/v5/app.css');
|
||||
|
||||
expect($appStyles)
|
||||
->toContain('ring-2 ring-coollabs dark:ring-1 dark:ring-warning')
|
||||
->and($v5Styles)
|
||||
->toMatch('/\.dark\s*\{[^}]*--ring:\s*var\(--warning\);/s')
|
||||
->toContain('ring-2 dark:ring-1 ring-ring');
|
||||
});
|
||||
9
tests/Unit/ScrollbarColorTest.php
Normal file
9
tests/Unit/ScrollbarColorTest.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
it('uses coollabs purple for scrollbars in dark mode', function () {
|
||||
$utilities = file_get_contents(dirname(__DIR__, 2).'/resources/css/utilities.css');
|
||||
|
||||
expect($utilities)
|
||||
->toContain('dark:scrollbar-thumb-coollabs-100')
|
||||
->not->toContain('dark:scrollbar-thumb-warning');
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"version": "1.0.14"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.16"
|
||||
"version": "1.0.17"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.22"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export default defineConfig(({ mode }) => {
|
|||
viteHost
|
||||
).trim();
|
||||
const vitePort = Number(process.env.VITE_PORT || env.VITE_PORT || 5173);
|
||||
const viteProtocol = (
|
||||
process.env.VITE_PROTOCOL ||
|
||||
env.VITE_PROTOCOL ||
|
||||
"http"
|
||||
).trim();
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
|
|
@ -34,10 +39,11 @@ export default defineConfig(({ mode }) => {
|
|||
allowedHosts: true,
|
||||
// App (:8000) and Vite (:5173) are different origins; allow any host in dev
|
||||
cors: true,
|
||||
origin: `http://${viteHost}:${vitePort}`,
|
||||
origin: `${viteProtocol}://${viteHost}:${vitePort}`,
|
||||
hmr: {
|
||||
host: viteHmrHost,
|
||||
clientPort: vitePort,
|
||||
protocol: viteProtocol === "https" ? "wss" : "ws",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue