Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai 2026-08-10 14:23:09 +02:00
commit 8c9a7413d0
91 changed files with 2889 additions and 2201 deletions

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ docker/coolify-realtime/node_modules
.DS_Store .DS_Store
CHANGELOG.md CHANGELOG.md
/.workspaces /.workspaces
/.superpowers/
tests/Browser/Screenshots tests/Browser/Screenshots
tests/v4/Browser/Screenshots tests/v4/Browser/Screenshots
ref ref

View 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');
}
}

View file

@ -4,6 +4,7 @@
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
@ -15,6 +16,8 @@ class Resources extends Component
public array $selectedStorages = []; public array $selectedStorages = [];
public array $selectedVolumeStorages = [];
public function mount(): void public function mount(): void
{ {
$this->authorize('view', $this->storage); $this->authorize('view', $this->storage);
@ -26,6 +29,13 @@ public function mount(): void
foreach ($backups as $backup) { foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id; $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 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}."); $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() public function render()
{ {
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
@ -92,8 +157,15 @@ public function render()
->orderBy('name') ->orderBy('name')
->get(['id', 'name', 'is_usable']); ->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', [ return view('livewire.storage.resources', [
'groupedBackups' => $backups, 'groupedBackups' => $backups,
'volumeBackups' => $volumeBackups,
'allStorages' => $allStorages, 'allStorages' => $allStorages,
]); ]);
} }

View file

@ -12,69 +12,69 @@
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"danharrin/livewire-rate-limiting": "^2.1.0", "danharrin/livewire-rate-limiting": "^2.2.1",
"doctrine/dbal": "^4.4.1", "doctrine/dbal": "^4.4.4",
"guzzlehttp/guzzle": "^7.10.0", "guzzlehttp/guzzle": "^7.15.3",
"inertiajs/inertia-laravel": "^3.1", "inertiajs/inertia-laravel": "^3.3",
"laravel/fortify": "^1.34.0", "laravel/fortify": "^1.37.3",
"laravel/framework": "^12.49.0", "laravel/framework": "^12.65.0",
"laravel/horizon": "^5.43.0", "laravel/horizon": "^5.48.2",
"laravel/mcp": "^0.6.7", "laravel/mcp": "^0.6.7",
"laravel/nightwatch": "^1.24", "laravel/nightwatch": "^1.28.6",
"laravel/pail": "^1.2.4", "laravel/pail": "^1.2.7",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11", "laravel/prompts": "^0.3.22|^0.3.22|^0.3.22",
"laravel/sanctum": "^4.3.0", "laravel/sanctum": "^4.3.3",
"laravel/socialite": "^5.24.2", "laravel/socialite": "^5.29.0",
"laravel/tinker": "^2.11.0", "laravel/tinker": "^2.11.1",
"laravel/ui": "^4.6.1", "laravel/ui": "^4.6.3",
"lcobucci/jwt": "^5.6.0", "lcobucci/jwt": "^5.6.0",
"league/flysystem-aws-s3-v3": "^3.31.0", "league/flysystem-aws-s3-v3": "^3.35.2",
"league/flysystem-sftp-v3": "^3.31", "league/flysystem-sftp-v3": "^3.33",
"livewire/livewire": "^3.7.8", "livewire/livewire": "^3.8.3",
"log1x/laravel-webfonts": "^2.0.1", "log1x/laravel-webfonts": "^2.0.1",
"lorisleiva/laravel-actions": "^2.9.1", "lorisleiva/laravel-actions": "^2.10.2",
"nubs/random-name-generator": "^2.2", "nubs/random-name-generator": "^2.2",
"phpseclib/phpseclib": "^3.0.49", "phpseclib/phpseclib": "^3.0.56",
"pion/laravel-chunk-upload": "^1.5.6", "pion/laravel-chunk-upload": "^1.6.1",
"poliander/cron": "^3.3.0", "poliander/cron": "^3.3.1",
"purplepixie/phpdns": "^2.3.6", "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", "resend/resend-laravel": "^0.20.0",
"sentry/sentry-laravel": "^4.20.1", "sentry/sentry-laravel": "^4.27.0",
"socialiteproviders/authentik": "^5.2", "socialiteproviders/authentik": "^5.3",
"socialiteproviders/clerk": "^5.1", "socialiteproviders/clerk": "^5.1",
"socialiteproviders/discord": "^4.2", "socialiteproviders/discord": "^4.2",
"socialiteproviders/google": "^4.1", "socialiteproviders/google": "^4.1",
"socialiteproviders/infomaniak": "^4.0", "socialiteproviders/infomaniak": "^4.0",
"socialiteproviders/microsoft-azure": "^5.2", "socialiteproviders/microsoft-azure": "^5.2",
"socialiteproviders/zitadel": "^4.2", "socialiteproviders/zitadel": "^4.2",
"spatie/laravel-activitylog": "^4.11.0", "spatie/laravel-activitylog": "^4.12.3",
"spatie/laravel-data": "^4.19.1", "spatie/laravel-data": "^4.23.0",
"spatie/laravel-markdown": "^2.7.1", "spatie/laravel-markdown": "^2.8.0",
"spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/laravel-schemaless-attributes": "^2.6.0",
"spatie/url": "^2.4", "spatie/url": "^2.4",
"stevebauman/purify": "^6.3.1", "stevebauman/purify": "^6.3.2",
"stripe/stripe-php": "^16.6.0", "stripe/stripe-php": "^16.6.0",
"symfony/yaml": "^7.4.1", "symfony/yaml": "^7.4.15",
"visus/cuid2": "^6.0.0", "visus/cuid2": "^6.0.0",
"yosymfony/toml": "^1.0.4", "yosymfony/toml": "^1.0.4",
"zircote/swagger-php": "^5.8.0" "zircote/swagger-php": "^5.8.3"
}, },
"require-dev": { "require-dev": {
"driftingly/rector-laravel": "^2.1.9", "driftingly/rector-laravel": "^2.5.0",
"fakerphp/faker": "^1.24.1", "fakerphp/faker": "^1.24.1",
"laravel/boost": "^2.1", "laravel/boost": "^2.4.8",
"laravel/dusk": "^8.3.4", "laravel/dusk": "^8.6.0",
"laravel/pint": "^1.27", "laravel/pint": "^1.30.4",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.3", "nunomaduro/collision": "^8.9.5",
"pestphp/pest": "^4.3.2", "pestphp/pest": "^4.7.8",
"pestphp/pest-plugin-browser": "^4.2", "pestphp/pest-plugin-browser": "^4.3.1",
"phpstan/phpstan": "^2.1.38", "phpstan/phpstan": "^2.2.8",
"rector/rector": "^2.3.5", "rector/rector": "^2.6.1",
"serversideup/spin": "^3.1.1", "serversideup/spin": "^3.3.0",
"spatie/laravel-ignition": "^2.10.0", "spatie/laravel-ignition": "^2.12.0",
"symfony/http-client": "^7.4.5" "symfony/http-client": "^7.4.16"
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,

1235
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
'coolify' => [ 'coolify' => [
'version' => '4.3.0', 'version' => '4.3.0',
'helper_version' => '1.0.14', 'helper_version' => '1.0.14',
'realtime_version' => '1.0.16', 'realtime_version' => '1.0.17',
'railpack_version' => '0.23.0', 'railpack_version' => '0.23.0',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),

View file

@ -13,7 +13,7 @@ services:
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly} - COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
- COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown} - COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}
ports: ports:
- "${APP_PORT:-8000}:8080" - "${DEV_BIND_ADDRESS:-0.0.0.0}:${APP_PORT:-8000}:8080"
- "${FORWARD_FLUX_PORT:-6443}:6443" - "${FORWARD_FLUX_PORT:-6443}:6443"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -82,8 +82,8 @@ services:
env_file: env_file:
- .env - .env
ports: ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001" - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002" - "${DEV_BIND_ADDRESS:-0.0.0.0}:6002:6002"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
@ -111,8 +111,9 @@ services:
# Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access # Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access
VITE_HOST: "${VITE_HOST:-localhost}" VITE_HOST: "${VITE_HOST:-localhost}"
VITE_PORT: "${VITE_PORT:-5173}" VITE_PORT: "${VITE_PORT:-5173}"
VITE_PROTOCOL: "${VITE_PROTOCOL:-http}"
ports: ports:
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}" - "${DEV_BIND_ADDRESS:-0.0.0.0}:${VITE_PORT:-5173}:${VITE_PORT:-5173}"
volumes: volumes:
- .:/var/www/html/:cached - .:/var/www/html/:cached
command: sh -c "npm install && npm run dev" command: sh -c "npm install && npm run dev"

View file

@ -60,7 +60,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16' image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17'
pull_policy: always pull_policy: always
container_name: coolify-realtime container_name: coolify-realtime
restart: always restart: always

View file

@ -7,10 +7,10 @@
"dependencies": { "dependencies": {
"@xterm/addon-fit": "0.11.0", "@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0", "@xterm/xterm": "6.0.0",
"cookie": "1.1.1", "cookie": "2.0.1",
"dotenv": "17.3.1", "dotenv": "17.4.2",
"node-pty": "1.1.0", "node-pty": "1.1.0",
"ws": "8.20.1" "ws": "8.21.3"
} }
}, },
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
@ -29,12 +29,12 @@
] ]
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.1.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=22"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -42,9 +42,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.3.1", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -70,9 +70,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.1", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View file

@ -4,9 +4,9 @@
"dependencies": { "dependencies": {
"@xterm/addon-fit": "0.11.0", "@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0", "@xterm/xterm": "6.0.0",
"cookie": "1.1.1", "cookie": "2.0.1",
"dotenv": "17.3.1", "dotenv": "17.4.2",
"node-pty": "1.1.0", "node-pty": "1.1.0",
"ws": "8.20.1" "ws": "8.21.3"
} }
} }

View file

@ -1,7 +1,7 @@
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import http from 'http'; import http from 'http';
import pty from 'node-pty'; import pty from 'node-pty';
import cookie from 'cookie'; import { parseCookie } from 'cookie';
import 'dotenv/config'; import 'dotenv/config';
import { import {
extractHereDocContent, extractHereDocContent,
@ -96,7 +96,7 @@ const server = http.createServer((req, res) => {
}); });
const getSessionCookie = (req) => { const getSessionCookie = (req) => {
const cookies = cookie.parse(req.headers.cookie || ''); const cookies = parseCookie(req.headers.cookie || '');
const xsrfToken = cookies['XSRF-TOKEN']; const xsrfToken = cookies['XSRF-TOKEN'];
const appName = process.env.APP_NAME || 'laravel'; const appName = process.env.APP_NAME || 'laravel';
const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`; const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;

View file

@ -60,7 +60,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16' image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17'
pull_policy: always pull_policy: always
container_name: coolify-realtime container_name: coolify-realtime
restart: always restart: always

View file

@ -10,7 +10,7 @@
"version": "1.0.14" "version": "1.0.14"
}, },
"realtime": { "realtime": {
"version": "1.0.16" "version": "1.0.17"
}, },
"sentinel": { "sentinel": {
"version": "0.0.22" "version": "0.0.22"

1795
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,31 +11,34 @@
"clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans" "clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.1.18", "@tailwindcss/postcss": "4.3.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.1.1", "jsdom": "^30.0.1",
"laravel-vite-plugin": "3.1.0", "laravel-vite-plugin": "3.1.3",
"postcss": "8.5.15", "postcss": "8.5.26",
"shadcn": "^4.11.0", "shadcn": "^4.11.0",
"tailwind-scrollbar": "4.0.2", "tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18", "tailwindcss": "4.3.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "8.0.16", "vite": "8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"overrides": {
"@babel/plugin-transform-runtime": "^7.29.0"
},
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
"@inertiajs/react": "^3.3.0", "@inertiajs/react": "^3.3.0",
"@inertiajs/vite": "^3.3.0", "@inertiajs/vite": "^3.3.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.20",
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "5.5.0", "@xterm/xterm": "6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"playwright": "^1.58.2", "playwright": "^1.58.2",

View file

@ -1,6 +1,7 @@
@import "./fonts.css" layer(base); @import "./fonts.css" layer(base);
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "./utilities.css"; @import "./utilities.css";
@ -307,7 +308,7 @@ a {
button:focus-visible, button:focus-visible,
a: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 { label {
@ -366,24 +367,24 @@ .lds-heart {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); 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 { .dark .animate-spin {
color: var(--color-warning) !important; color: var(--color-coollabs) !important;
} }
.dark #nprogress .bar { .dark #nprogress .bar {
background: var(--color-warning) !important; background: var(--color-coollabs) !important;
} }
.dark #nprogress .peg { .dark #nprogress .peg {
box-shadow: box-shadow:
0 0 10px var(--color-warning), 0 0 10px var(--color-coollabs),
0 0 5px var(--color-warning) !important; 0 0 5px var(--color-coollabs) !important;
} }
.dark #nprogress .spinner-icon { .dark #nprogress .spinner-icon {
border-top-color: var(--color-warning) !important; border-top-color: var(--color-coollabs) !important;
border-left-color: var(--color-warning) !important; border-left-color: var(--color-coollabs) !important;
} }
html[data-theme="custom"] .loading-indicator, html[data-theme="custom"] .loading-indicator,
@ -2673,6 +2674,16 @@ .dark .data-table-cell-dash {
/* Shared logs viewer (deployment + runtime) mobile-first toolbar /* Shared logs viewer (deployment + runtime) mobile-first toolbar
Mobile stacks: search meta full-width actions (no side-by-side overlap). */ 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 { .logs-viewer-toolbar {
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--color-neutral-200); border-bottom: 1px solid var(--color-neutral-200);
@ -2934,7 +2945,7 @@ .dark .logs-viewer-btn-active {
.logs-viewer-viewport { .logs-viewer-viewport {
min-width: 0; min-width: 0;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem 2rem;
} }
.logs-viewer-line { .logs-viewer-line {
@ -3024,7 +3035,7 @@ @media (min-width: 640px) {
} }
.logs-viewer-viewport { .logs-viewer-viewport {
padding: 0.5rem 1rem; padding: 0.5rem 1rem 2rem;
} }
.logs-viewer-line { .logs-viewer-line {

View file

@ -131,15 +131,15 @@ @utility button {
} }
@utility button-highlighted { @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 { @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 { @utility loading-indicator {
@apply text-coollabs dark:text-warning; @apply text-coollabs dark:text-coollabs;
} }
/* Compact icon-only control (gear, chevrons, etc.) */ /* Compact icon-only control (gear, chevrons, etc.) */
@ -273,7 +273,7 @@ @utility icon {
} }
@utility scrollbar { @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 { @utility main {

View file

@ -145,7 +145,7 @@ @layer base {
} }
:where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible { :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, html,

View file

@ -52,12 +52,12 @@
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'), 'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
], ],
[ [
'label' => 'Deployment', 'label' => 'Deployment Logs',
'route' => 'project.application.deployment.index', 'route' => 'project.application.deployment.index',
'active' => str($currentRoute)->startsWith('project.application.deployment'), 'active' => str($currentRoute)->startsWith('project.application.deployment'),
], ],
[ [
'label' => 'Runtime', 'label' => 'Runtime Logs',
'route' => 'project.application.logs', 'route' => 'project.application.logs',
'active' => $currentRoute === 'project.application.logs', 'active' => $currentRoute === 'project.application.logs',
], ],
@ -142,8 +142,8 @@
'Persistent Storage' => 'storages', 'Persistent Storage' => 'storages',
'Backups' => 'database', 'Backups' => 'database',
'Terminal' => 'browser-terminal', 'Terminal' => 'browser-terminal',
'Deployment' => 'time-back', 'Deployment Logs' => 'time-back',
'Runtime' => 'unordered-list', 'Runtime Logs' => 'unordered-list',
'Git Source' => 'sources', 'Git Source' => 'sources',
'Servers' => 'servers', 'Servers' => 'servers',
'Scheduled Tasks' => 'calendar', 'Scheduled Tasks' => 'calendar',
@ -160,14 +160,17 @@
// Discord-style groups for the settings sidebar // Discord-style groups for the settings sidebar
$menuGroups = [ $menuGroups = [
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'], 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'], 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Logs' => ['Deployment', 'Runtime'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
]; ];
$groupedMenuItems = collect($menuGroups) $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()); ->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page // In-page sections (cards) shown as sub-items under the active page

View file

@ -14,7 +14,7 @@
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()], ['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' => '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' => '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' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'], ['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'], ['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
@ -32,14 +32,18 @@
]); ]);
$menuGroups = [ $menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'], 'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'],
'Automation' => ['Webhooks', 'Healthcheck'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics'],
'Logs' => ['Runtime'], 'Deploy' => ['Servers'],
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'], 'Automation' => ['Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone'],
]; ];
$groupedItems = collect($menuGroups) $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()); ->filter(fn ($items) => $items->isNotEmpty());
$pageSections = $database->type() === 'standalone-postgresql' $pageSections = $database->type() === 'standalone-postgresql'

View file

@ -13,10 +13,12 @@
'value' => null, // initial value when wire=false 'value' => null, // initial value when wire=false
'disabled' => false, 'disabled' => false,
'tooltip' => true, 'tooltip' => true,
'portal' => false,
]) ])
@php @php
$triggerId = ($htmlId ?? $id).'-trigger'; $triggerId = ($htmlId ?? $id).'-trigger';
$panelId = ($htmlId ?? $id).'-panel';
@endphp @endphp
<div class="w-full min-w-0"> <div class="w-full min-w-0">
@ -41,6 +43,7 @@
@endif @endif
<div class="relative min-w-0" x-data="{ <div class="relative min-w-0" x-data="{
open: false, open: false,
positioned: false,
options: @js(array_values($options)), options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif, value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
get current() { get current() {
@ -53,11 +56,42 @@
if (String(option.value) === String(this.value)) return; if (String(option.value) === String(this.value)) return;
this.value = option.value; this.value = option.value;
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif @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') }} }" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }} {{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false"> @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
<button id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open" <button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox" @disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif> :aria-expanded="open" @if ($tooltip) :title="current" @endif>
<span class="listbox-trigger-label" x-text="current"></span> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg> </svg>
</button> </button>
<div class="listbox-panel" x-show="open" x-cloak role="listbox"> @if ($portal)
<div x-show="options.length === 0" <template x-teleport="body">
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim"> <div id="{{ $panelId }}" class="listbox-panel" style="position: fixed; z-index: 9999" x-show="open"
{{ $emptyText }} x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
</div> x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<template x-for="option in options" :key="String(option.value)"> <div x-show="options.length === 0"
<button type="button" class="listbox-option" role="option" class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
:class="{ 'listbox-option-disabled': option.disabled }" {{ $emptyText }}
:aria-selected="String(option.value) === String(value)" @click="choose(option)"> </div>
<span class="truncate" x-text="option.label"></span> <template x-for="option in options" :key="String(option.value)">
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg" <button type="button" class="listbox-option" role="option"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" :class="{ 'listbox-option-disabled': option.disabled }"
class="size-3.5 shrink-0"> :aria-selected="String(option.value) === String(value)" @click="choose(option)">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> <span class="truncate" x-text="option.label"></span>
</svg> <svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
</button> 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> </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>
</div> </div>

View file

@ -1,5 +1,6 @@
<div x-data="{ <div x-data="{
visible: false, visible: false,
positioned: false,
text: '', text: '',
x: 0, x: 0,
y: 0, y: 0,
@ -7,7 +8,10 @@
activeTarget: null, activeTarget: null,
isIconAction(target) { isIconAction(target) {
if (target.matches('[data-icon-tooltip-ignore]')) return false; 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) { prepare(root) {
root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => { root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => {
@ -25,10 +29,12 @@
show(event) { show(event) {
const target = this.findTarget(event); const target = this.findTarget(event);
if (!target) return; if (!target) return;
if (target === this.activeTarget && this.visible) return;
const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label'); const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label');
if (!text) return; if (!text) return;
this.activeTarget = target; this.activeTarget = target;
this.text = text; this.text = text;
this.positioned = false;
this.visible = true; this.visible = true;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
this.below = rect.top < 48; this.below = rect.top < 48;
@ -37,11 +43,13 @@
this.$nextTick(() => { this.$nextTick(() => {
const width = this.$refs.tooltip?.offsetWidth || 0; const width = this.$refs.tooltip?.offsetWidth || 0;
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x)); this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
this.$nextTick(() => this.positioned = true);
}); });
}, },
hide(event) { hide(event) {
if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return; if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return;
this.visible = false; this.visible = false;
this.positioned = false;
this.activeTarget = null; this.activeTarget = null;
}, },
init() { init() {
@ -62,7 +70,7 @@
}" class="contents"> }" class="contents">
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text" <div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
:style="`left: ${x}px; top: ${y}px;`" :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"> 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>
</div> </div>

View file

@ -67,7 +67,7 @@ class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg
</div> </div>
@endif @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()) @if (isSubscribed() || !isCloud())
{{-- Workspace --}} {{-- Workspace --}}
<li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li> <li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li>

View file

@ -13,7 +13,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], ['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' => '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' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
@ -31,14 +31,17 @@
]); ]);
$menuGroups = [ $menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'], 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Automation' => ['Scheduled Tasks', 'Webhooks'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Logs' => ['Runtime'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'], 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
]; ];
$groupedItems = collect($menuGroups) $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()); ->filter(fn ($items) => $items->isNotEmpty());
@endphp @endphp

View file

@ -14,15 +14,15 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
</div> </div>
<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')" <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'" :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"> aria-label="List view" title="List view">
<x-reicon name="unordered-list" class="size-3.5" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')" <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'" :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"> aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" /> <x-reicon name="grid" class="size-3.5" />

View file

@ -13,6 +13,7 @@
appearanceOpen: false, appearanceOpen: false,
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed', themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColorFrame: null,
avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null), avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null),
setTheme(type, closeMenu = true) { setTheme(type, closeMenu = true) {
this.theme = type; this.theme = type;
@ -31,9 +32,29 @@
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor)); document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
}, },
setThemeColor() { previewThemeColor(color) {
localStorage.setItem('themeColor', this.themeColor); this.themeColor = color;
this.setTheme('custom', false);
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" }" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false"
@click.outside="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"> <template x-if="open">
<div @class([ <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, 'right-0! left-auto!' => ! $sidebar,
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $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="min-w-0 px-2 py-1.5">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div> <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" <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" /> stroke-linecap="round" stroke-linejoin="round" />
</svg> </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" aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" /> class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
</div> </div>

View file

@ -56,18 +56,18 @@
} }
" "
@if ($targets) wire:target="{{ $targets }}" @endif @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"> 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-fg sm:whitespace-nowrap">{{ $label }}</span> <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"> <div class="flex shrink-0 items-center justify-end gap-2">
<button type="button" onclick="window.location.reload()" <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 Reset
</button> </button>
<button type="button" wire:click="{{ $action }}" wire:loading.attr="disabled" <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]"> 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> <span>Save changes</span>
<kbd <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> </button>
</div> </div>
</div> </div>

View file

@ -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. Select where to deploy your applications and databases. You can add more servers later.
</x-slot:question> </x-slot:question>
<x-slot:actions> <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 <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]" 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')"> 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" /> 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> </svg>
<div> <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"> <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> </p>
</div> </div>
</div> </div>
</button> </button>
</div>
</section>
@can('viewAny', App\Models\CloudProviderToken::class) @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') @if ($currentState === 'select-server-type')
<x-modal-input title="Connect a Hetzner Server" isFullWidth> <x-modal-input title="Connect a Hetzner Server" isFullWidth>
<x-slot:content> <x-slot:content>
<div <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]"> 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"> <div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200" <img src="{{ asset('svgs/hetzner.svg') }}" alt="Hetzner"
xmlns="http://www.w3.org/2000/svg"> class="size-10 shrink-0">
<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>
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">Hetzner Cloud</h3> <h3 class="mb-1 text-[14px] font-semibold">Hetzner Cloud</h3>
<p class="text-sm dark:text-neutral-400"> <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 <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]"> 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"> <div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200" <img src="https://www.vultr.com/media/logo_ondark.svg" alt="Vultr"
xmlns="http://www.w3.org/2000/svg"> class="h-10 w-28 shrink-0 object-contain object-left">
<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>
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">Vultr Cloud</h3> <h3 class="mb-1 text-[14px] font-semibold">Vultr Cloud</h3>
<p class="text-sm dark:text-neutral-400"> <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" /> <livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
</x-modal-input> </x-modal-input>
@endif @endif
</div>
</section>
@endcan @endcan
</div> </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" <x-forms.input placeholder="Optional: Note what this server hosts" label="Description"
id="remoteServerDescription" wire:model="remoteServerDescription" /> 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"> content-class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number" <x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" /> id="remoteServerPort" wire:model="remoteServerPort" />
<div> <div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser" <x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="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> </div>
</x-forms.collapsible> </x-forms.collapsible>
<x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button> <x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button>

View file

@ -178,15 +178,22 @@ class="button button-highlighted">
}; };
@endphp @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]"> 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]) }}" @if ($server->isMetricsEnabled())
{{ wireNavigate() }} class="absolute inset-0 rounded-xl" <livewire:dashboard.server-metrics-chart :server="$server"
aria-label="Open {{ $server->name }}"></a> :key="'dashboard-server-metrics-'.$server->uuid" />
@endif
<div class="flex min-w-0 items-start gap-3"> <div class="pointer-events-none relative z-10 flex min-w-0 items-start gap-3">
<div <div title="{{ $serverStatus }}" aria-label="Server status: {{ $serverStatus }}"
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"> @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" /> <x-reicon name="servers" class="size-4" />
</div> </div>
<div class="min-w-0 flex-1"> <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> </p>
</div> </div>
</div> </div>
</a>
<div class="mt-auto flex items-center pt-4">
<x-status-badge :status="$serverStatus" :type="$serverStatusType" />
</div>
</article>
@endforeach @endforeach
</div> </div>
@endif @endif

View file

@ -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 }}" <a wire:key="dashboard-active-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }} 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"> <div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg"> <p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }} {{ $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 }}" <a wire:key="dashboard-recent-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }} 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"> <div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg"> <p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }} {{ $deployment->application_name }}

View file

@ -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>

View file

@ -120,12 +120,6 @@
this.searchQuery = ''; this.searchQuery = '';
this.allSearchableItems = []; this.allSearchableItems = [];
this.isPaletteTransitioning = false; 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) { runPaletteTransition(callback) {
this.isPaletteTransitioning = true; this.isPaletteTransitioning = true;
@ -299,16 +293,16 @@
}"> }">
<!-- Command palette --> <!-- 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]"> 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>
<div x-show="modalOpen" x-trap.inert="modalOpen" <div x-show="modalOpen" x-trap.inert="modalOpen"
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })" x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
x-transition:enter="ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-2 scale-[0.98]" x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"
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]"
class="command-palette relative mx-auto" class="command-palette relative mx-auto"
@click.stop> @click.stop>

View file

@ -3,6 +3,7 @@
<div x-data="{ <div x-data="{
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed', themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColorFrame: null,
init() { init() {
localStorage.setItem('theme', this.theme); localStorage.setItem('theme', this.theme);
this.applyTheme(); this.applyTheme();
@ -12,9 +13,29 @@
localStorage.setItem('theme', type); localStorage.setItem('theme', type);
this.applyTheme(); this.applyTheme();
}, },
setThemeColor() { previewThemeColor(color) {
localStorage.setItem('themeColor', this.themeColor); this.themeColor = color;
this.setTheme('custom');
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() { applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 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> </p>
</div> </div>
@if ($option['value'] === 'custom') @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" aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" /> class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
@endif @endif

View file

@ -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"> 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"> <template x-for="option in sortOptions" :key="option.value">
<button type="button" <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"> x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span> <span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning" <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>
<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')" <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' :class="viewMode === 'table'
? ?
'control-selected' : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? ?
'control-selected' : '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"> <div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="project.addResourceHref" :href="project.addResourceHref" <a x-show="project.addResourceHref" :href="project.addResourceHref"
{{ wireNavigate() }} {{ 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}`"> title="Add resource" :aria-label="`Add resource to ${project.name}`">
<x-reicon name="plus" class="size-3" /> <x-reicon name="plus" class="size-3" />
</a> </a>
<a x-show="project.settingsHref" :href="project.settingsHref" {{ wireNavigate() }} <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}`"> title="Project settings" :aria-label="`Open settings for ${project.name}`">
<x-reicon name="settings" class="size-3" /> <x-reicon name="settings" class="size-3" />
</a> </a>

View file

@ -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"> 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"> <template x-for="option in sortOptions" :key="option.value">
<button type="button" <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"> x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span> <span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning" <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>
<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')" <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' :class="viewMode === 'table'
? ?
'control-selected' : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? ?
'control-selected' : 'control-selected' :

View file

@ -18,7 +18,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], ['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' => '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' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
@ -33,14 +33,17 @@
]); ]);
$menuGroups = [ $menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'], 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Automation' => ['Scheduled Tasks', 'Webhooks'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Logs' => ['Runtime'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'], 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
]; ];
$groupedItems = collect($menuGroups) $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()); ->filter(fn ($items) => $items->isNotEmpty());
$storageSections = $applications $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>
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start"> <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<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')" <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' :class="viewMode === 'table'
? 'control-selected' ? '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'" : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? 'control-selected' ? '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'" : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"

View file

@ -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"> 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"> <template x-for="option in sortOptions" :key="option.value">
<button type="button" <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"> x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span> <span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning" <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>
<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')" <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' :class="viewMode === 'table'
? ?
'control-selected' : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? ?
'control-selected' : '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"> <div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="environment.addResourceHref" :href="environment.addResourceHref" <a x-show="environment.addResourceHref" :href="environment.addResourceHref"
{{ wireNavigate() }} {{ 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}`"> title="Add resource" :aria-label="`Add resource to ${environment.name}`">
<x-reicon name="plus" class="size-3" /> <x-reicon name="plus" class="size-3" />
</a> </a>
<a x-show="environment.settingsHref" :href="environment.settingsHref" <a x-show="environment.settingsHref" :href="environment.settingsHref"
{{ wireNavigate() }} {{ 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" title="Environment settings"
:aria-label="`Open settings for ${environment.name}`"> :aria-label="`Open settings for ${environment.name}`">
<x-reicon name="settings" class="size-3" /> <x-reicon name="settings" class="size-3" />

View file

@ -15,91 +15,89 @@
</div> </div>
@if (!$selectedType) @if (!$selectedType)
<div class="application-settings-form"> <div class="application-settings-form flex flex-col gap-6">
<section class="application-settings-section"> <section class="application-settings-section">
<div class="application-settings-section-body is-flush"> <div class="application-settings-section-header">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4"> <h2 class="application-settings-section-title">Add a server</h2>
@can('viewAny', App\Models\CloudProviderToken::class) <p class="application-settings-section-description">Connect a server you already manage.</p>
<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> </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> </div>
</section> </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> </div>
@else @else
<div class="application-settings-form"> <div class="application-settings-form">

View file

@ -41,14 +41,19 @@ class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
default => 'Validation required', default => 'Validation required',
}; };
$statusType = match (true) {
$isReady => 'success',
$isTransferredAway || $server->settings->force_disabled => 'error',
default => 'warning',
};
return [ return [
'uuid' => $server->uuid, 'uuid' => $server->uuid,
'name' => $server->name, 'name' => $server->name,
'description' => $server->description ?: 'No description', 'description' => $server->description ?: 'No description',
'href' => route('server.show', ['server_uuid' => $server->uuid]), 'href' => route('server.show', ['server_uuid' => $server->uuid]),
'status' => $status, 'status' => $status,
'statusType' => $isReady ? 'success' : 'error', 'statusType' => $statusType,
'ready' => $isReady,
]; ];
})->values(); })->values();
@endphp @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 x-text="filteredServers.length === 1 ? 'server' : 'servers'"></span>
</span> </span>
<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')" <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' :class="viewMode === 'table'
? 'control-selected' ? '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'" : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? 'control-selected' ? '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'" : '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() }} <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]"> 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 items-start gap-3">
<div <div :title="server.status" :aria-label="`Server status: ${server.status}`"
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"> 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" /> <x-reicon name="servers" class="size-4" />
</div> </div>
<div class="min-w-0 flex-1"> <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> x-text="server.description"></p>
</div> </div>
</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> </a>
</template> </template>
</div> </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() }} <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]"> 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 min-w-0 items-center gap-3">
<div <div :title="server.status" :aria-label="`Server status: ${server.status}`"
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"> 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" /> <x-reicon name="servers" class="size-4" />
</div> </div>
<div class="min-w-0"> <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> x-text="server.description"></p>
</div> </div>
</div> </div>
<div> <div class="text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<x-status-badge dynamic> <span x-text="server.status"></span>
<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> </div>
</a> </a>
</template> </template>

View file

@ -83,9 +83,12 @@ class="listbox-option justify-start! gap-2.5!" role="menuitem">
helper="Non-root SSH users are experimental." /> helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required /> <x-forms.input type="number" id="port" label="Port" required />
</div> </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." 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-forms.collapsible>
</x-application.settings-section> </x-application.settings-section>
</form> </form>

View file

@ -6,9 +6,9 @@ class="h-8! rounded-lg! border-neutral-200! bg-white! py-0! pr-8! text-[12px]! s
</div> </div>
<div class="flex items-center gap-3"> <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> <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]"> <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-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('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-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> <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> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
<div x-data="{ search: '' }" class="application-settings-form"> <div x-data="{ search: '' }" class="application-settings-form">
<x-application.settings-section title="Backup schedules" <x-application.settings-section title="Backup schedules"
description="Schedules currently writing backup data to this storage." flush> 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" <x-empty title="No backup schedules use this storage"
description="Select this storage from a database or volume backup schedule to see it here." description="Select this storage from a database or volume backup schedule to see it here."
icon-name="storages" size="sm" /> 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="overflow-x-auto">
<div <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"> 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>Frequency</div>
<div>Status</div> <div>Status</div>
<div>Storage</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> <span class="text-neutral-500 dark:text-fg-dim">{{ $backup->frequency }}</span>
@endif @endif
</div> </div>
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'" <div class="flex items-center">
:type="$backup->enabled ? 'success' : 'warning'" /> <x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
:type="$backup->enabled ? 'success' : 'warning'" />
</div>
<div class="flex items-end gap-2"> <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" <button type="button" class="button shrink-0"
wire:click="moveBackup({{ $backup->id }})">Move</button> wire:click="moveBackup({{ $backup->id }})">Move</button>
<button type="button" class="button shrink-0 text-error" <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> </div>
@endforeach @endforeach
@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> </div>
@endif @endif
</x-application.settings-section> </x-application.settings-section>

View file

@ -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"> 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"> <template x-for="option in sortOptions" :key="option.value">
<button type="button" <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"> x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span> <span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning" <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>
<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')" <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' :class="viewMode === 'table'
? ?
'control-selected' : '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" /> <x-reicon name="unordered-list" class="size-3.5" />
</button> </button>
<button type="button" x-on:click="setViewMode('grid')" <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' :class="viewMode === 'grid'
? ?
'control-selected' : 'control-selected' :

View file

@ -70,11 +70,16 @@
themeAccents: @js($consoleThemeAccents), themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system', consoleTheme: 'system',
themeOpen: false, themeOpen: false,
get filteredTargets() { get filteredTargetGroups() {
const query = this.targetSearch.trim().toLowerCase(); const query = this.targetSearch.trim().toLowerCase();
return query const targets = query
? this.targets.filter((target) => target.label.toLowerCase().includes(query)) ? this.targets.filter((target) => target.label.toLowerCase().includes(query))
: this.targets; : 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() { init() {
const savedTheme = localStorage.getItem('coolify-console-theme'); 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 class="mt-1 text-xs text-white/45">Connect a reachable server and enable terminal access.</div>
</div> </div>
@else @else
<template x-for="target in filteredTargets" :key="target.value"> <template x-for="group in filteredTargetGroups" :key="group.type">
<button type="button" <section class="not-last:mb-2">
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" <div class="px-3 py-2 text-[10px] font-semibold tracking-wider text-white/40 uppercase"
x-on:click="selectTarget(target)"> x-text="group.label"></div>
<x-reicon name="servers" x-show="target.type === 'server'" <template x-for="target in group.targets" :key="target.value">
class="size-4 shrink-0 text-white/40" /> <button type="button"
<x-reicon name="layers" x-show="target.type === 'container'" 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"
class="size-4 shrink-0 text-white/40" /> x-on:click="selectTarget(target)">
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span> <x-reicon name="servers" x-show="target.type === 'server'"
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" /> class="size-4 shrink-0 text-white/40" />
</button> <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> </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"> class="px-3 py-8 text-center text-sm text-white/50">
No matching targets No matching targets
</div> </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> </div>
<div class="terminal-target-list max-h-72 overflow-y-auto p-1"> <div class="terminal-target-list max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value"> <template x-for="group in filteredTargetGroups" :key="group.type">
<button type="button" <section class="not-last:mb-1">
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" <div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
x-on:click="selectTarget(target)"> x-text="group.label"></div>
<x-reicon name="servers" x-cloak x-show="target.type === 'server'" <template x-for="target in group.targets" :key="target.value">
class="size-3.5 shrink-0 text-white/35" /> <button type="button"
<x-reicon name="layers" x-cloak x-show="target.type === 'container'" 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"
class="size-3.5 shrink-0 text-white/35" /> x-on:click="selectTarget(target)">
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span> <x-reicon name="servers" x-cloak x-show="target.type === 'server'"
<svg x-show="$wire.selected_uuid === target.value" class="size-3.5 shrink-0 text-white/35" />
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none" <x-reicon name="layers" x-cloak x-show="target.type === 'container'"
aria-hidden="true"> class="size-3.5 shrink-0 text-white/35" />
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" <span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
stroke-width="1.4" stroke-linecap="round" <span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
stroke-linejoin="round" /> x-text="target.type"></span>
</svg> <svg x-show="$wire.selected_uuid === target.value"
</button> 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> </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"> class="px-2 py-5 text-center text-[11px] text-white/35">
No matching targets No matching targets
</div> </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> </div>
<div class="max-h-72 overflow-y-auto p-1"> <div class="max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value"> <template x-for="group in filteredTargetGroups" :key="group.type">
<button type="button" <section class="not-last:mb-1">
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" <div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
x-on:click="selectTarget(target)"> x-text="group.label"></div>
<x-reicon name="servers" x-show="target.type === 'server'" <template x-for="target in group.targets" :key="target.value">
class="size-3.5 shrink-0 text-white/35" /> <button type="button"
<x-reicon name="layers" x-show="target.type === 'container'" 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"
class="size-3.5 shrink-0 text-white/35" /> x-on:click="selectTarget(target)">
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span> <x-reicon name="servers" x-show="target.type === 'server'"
</button> 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> </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"> class="px-2 py-5 text-center text-[11px] text-white/35">
No matching targets No matching targets
</div> </div>

2
svgs/influxdb.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

7
svgs/termix.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -22,7 +22,7 @@ services:
volumes: volumes:
- firefly-upload:/var/www/html/storage/upload - firefly-upload:/var/www/html/storage/upload
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080"] test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
interval: 5s interval: 5s
timeout: 20s timeout: 20s
retries: 10 retries: 10

View file

@ -7,7 +7,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -7,7 +7,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -7,7 +7,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -8,7 +8,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -8,7 +8,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -8,7 +8,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -8,7 +8,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View file

@ -7,7 +7,7 @@
services: services:
forgejo: forgejo:
image: codeberg.org/forgejo/forgejo:8 image: codeberg.org/forgejo/forgejo:15
environment: environment:
- SERVICE_URL_FORGEJO_3000 - SERVICE_URL_FORGEJO_3000
- FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO} - FORGEJO__server__ROOT_URL=${SERVICE_URL_FORGEJO}

View 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

View file

@ -40,6 +40,7 @@ services:
content: | content: |
general_settings: general_settings:
proxy_batch_write_at: 60 proxy_batch_write_at: 60
store_prompts_in_spend_logs: false
router_settings: router_settings:
redis_host: os.environ/REDIS_HOST redis_host: os.environ/REDIS_HOST
@ -50,15 +51,15 @@ services:
litellm_settings: litellm_settings:
set_verbose: false set_verbose: false
json_logs: true json_logs: true
log_raw_request_response: true log_raw_request_response: false
# turn_off_message_logging: false # turn_off_message_logging: false
# redact_user_api_key_info: false # redact_user_api_key_info: false
service_callback: ["prometheus_system"] service_callback: ["prometheus_system"]
drop_params: true drop_params: true
# max_budget: 100 # max_budget: 100
# budget_duration: 30d # budget_duration: 30d
num_retries: 3 num_retries: 1
request_timeout: 600 request_timeout: 120
telemetry: false telemetry: false
cache: true cache: true
cache_params: cache_params:
@ -131,17 +132,21 @@ services:
- CMD - CMD
- python - python
- "-c" - "-c"
- "import requests as r;r.get('http://127.0.0.1:4000/health/liveliness').raise_for_status()" - "import urllib.request;urllib.request.urlopen('http://127.0.0.1:4000/health/liveliness',timeout=5)"
interval: 5s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s
command: command:
- "--config" - "--config"
- /app/config.yaml - /app/config.yaml
- "--port" - "--port"
- "4000" - "4000"
- "--num_workers" - "--num_workers"
- "8" - "2"
- "--run_gunicorn"
- "--max_requests_before_restart"
- "1000"
postgres: postgres:
image: "postgres:16-alpine" image: "postgres:16-alpine"
environment: environment:

View file

@ -47,10 +47,15 @@ x-shared-env: &shared-api-env
PHP_MAX_EXECUTION_TIME: "600" PHP_MAX_EXECUTION_TIME: "600"
PHP_UPLOAD_MAX_FILESIZE: "64M" PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "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: services:
opnform-api: opnform-api:
image: jhumanj/opnform-api:1.12.1 image: jhumanj/opnform-api:2.2.4
volumes: volumes:
- api-storage:/usr/share/nginx/html/storage - api-storage:/usr/share/nginx/html/storage
environment: environment:
@ -58,7 +63,7 @@ services:
<<: *shared-api-env <<: *shared-api-env
JWT_TTL: ${JWT_TTL:-1440} JWT_TTL: ${JWT_TTL:-1440}
JWT_SECRET: ${SERVICE_PASSWORD_JWTSECRET} 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_SITE_KEY: ${H_CAPTCHA_SITE_KEY}
H_CAPTCHA_SECRET_KEY: ${H_CAPTCHA_SECRET_KEY} H_CAPTCHA_SECRET_KEY: ${H_CAPTCHA_SECRET_KEY}
RE_CAPTCHA_SITE_KEY: ${RE_CAPTCHA_SITE_KEY} RE_CAPTCHA_SITE_KEY: ${RE_CAPTCHA_SITE_KEY}
@ -77,7 +82,7 @@ services:
start_period: 60s start_period: 60s
api-worker: api-worker:
image: jhumanj/opnform-api:1.12.1 image: jhumanj/opnform-api:2.2.4
volumes: volumes:
- api-storage:/usr/share/nginx/html/storage - api-storage:/usr/share/nginx/html/storage
environment: environment:
@ -98,7 +103,7 @@ services:
start_period: 30s start_period: 30s
api-scheduler: api-scheduler:
image: jhumanj/opnform-api:1.12.1 image: jhumanj/opnform-api:2.2.4
volumes: volumes:
- api-storage:/usr/share/nginx/html/storage - api-storage:/usr/share/nginx/html/storage
environment: environment:
@ -122,7 +127,7 @@ services:
start_period: 70s # Allow time for first scheduled run and cache write start_period: 70s # Allow time for first scheduled run and cache write
opnform-ui: opnform-ui:
image: jhumanj/opnform-client:1.12.1 image: jhumanj/opnform-client:2.2.4
environment: environment:
- NUXT_PUBLIC_APP_URL=/ - NUXT_PUBLIC_APP_URL=/
- NUXT_PUBLIC_API_BASE=/api - NUXT_PUBLIC_API_BASE=/api
@ -130,6 +135,8 @@ services:
- NUXT_PUBLIC_ENV=production - NUXT_PUBLIC_ENV=production
- NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=${H_CAPTCHA_SITE_KEY} - NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=${H_CAPTCHA_SITE_KEY}
- NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY=${RE_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: healthcheck:
test: test:
["CMD-SHELL", "wget --spider -q http://opnform-ui:3000/login || exit 1"] ["CMD-SHELL", "wget --spider -q http://opnform-ui:3000/login || exit 1"]
@ -142,9 +149,9 @@ services:
condition: service_healthy condition: service_healthy
postgresql: postgresql:
image: postgres:16 image: postgres:18
volumes: volumes:
- opnform-postgresql-data:/var/lib/postgresql/data - opnform-postgresql-data:/var/lib/postgresql
environment: environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL} - POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL} - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
@ -171,7 +178,7 @@ services:
# The nginx reverse proxy. # The nginx reverse proxy.
# used for reverse proxying the API service and Web service. # used for reverse proxying the API service and Web service.
nginx: nginx:
image: nginx:1.29.2 image: nginx:1.31.3
environment: environment:
- SERVICE_URL_NGINX - SERVICE_URL_NGINX
volumes: volumes:

View file

@ -7,7 +7,7 @@
services: services:
seaweedfs-master: seaweedfs-master:
image: chrislusf/seaweedfs:4.13 image: chrislusf/seaweedfs:4.41
environment: environment:
- SERVICE_URL_S3_8333 - SERVICE_URL_S3_8333
- AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} - AWS_ACCESS_KEY_ID=${SERVICE_USER_S3}
@ -61,7 +61,7 @@ services:
retries: 10 retries: 10
seaweedfs-admin: seaweedfs-admin:
image: chrislusf/seaweedfs:4.13 image: chrislusf/seaweedfs:4.41
environment: environment:
- SERVICE_URL_ADMIN_23646 - SERVICE_URL_ADMIN_23646
- SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} - SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN}

View file

@ -7,26 +7,42 @@
services: services:
sparkyfitness-frontend: sparkyfitness-frontend:
image: 'codewithcj/sparkyfitness:v0.15.7.3' # Released on Oct 18, 2025 image: 'codewithcj/sparkyfitness:v1.6.1'
environment: environment:
- SERVICE_URL_SPARKYFITNESS_80 - 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: depends_on:
- sparkyfitness-server - sparkyfitness-server
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: environment:
- 'SPARKY_FITNESS_LOG_LEVEL=${SPARKY_FITNESS_LOG_LEVEL:-info}' - '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_USER=${SERVICE_USER_POSTGRES}'
- SPARKY_FITNESS_DB_HOST=sparkyfitness-db - SPARKY_FITNESS_DB_HOST=sparkyfitness-db
- 'SPARKY_FITNESS_DB_NAME=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}' - 'SPARKY_FITNESS_DB_NAME=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
- 'SPARKY_FITNESS_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}' - 'SPARKY_FITNESS_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- 'SPARKY_FITNESS_DB_PORT=${SPARKY_FITNESS_DB_PORT:-5432}' - '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}' - '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}' - '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_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}' - '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_HOST=${SPARKY_FITNESS_EMAIL_HOST:-smtp.gmail.com}'
- 'SPARKY_FITNESS_EMAIL_PORT=${SPARKY_FITNESS_EMAIL_PORT:-587}' - 'SPARKY_FITNESS_EMAIL_PORT=${SPARKY_FITNESS_EMAIL_PORT:-587}'
- 'SPARKY_FITNESS_EMAIL_SECURE=${SPARKY_FITNESS_EMAIL_SECURE:-false}' - 'SPARKY_FITNESS_EMAIL_SECURE=${SPARKY_FITNESS_EMAIL_SECURE:-false}'
@ -40,7 +56,7 @@ services:
- 'sparkyfitness-server-uploads:/app/SparkyFitnessServer/uploads' - 'sparkyfitness-server-uploads:/app/SparkyFitnessServer/uploads'
sparkyfitness-db: sparkyfitness-db:
image: 'postgres:15-alpine' image: 'postgres:18-alpine'
environment: environment:
- 'POSTGRES_DB=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}' - 'POSTGRES_DB=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}' - 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
@ -52,5 +68,5 @@ services:
timeout: 20s timeout: 20s
retries: 10 retries: 10
volumes: volumes:
- 'sparkyfitness-db-postgresql:/var/lib/postgresql/data' - 'sparkyfitness-db-postgresql:/var/lib/postgresql'

View 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

View 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

View file

@ -14,12 +14,18 @@
->toContain("['value' => 'custom', 'label' => 'Custom'") ->toContain("['value' => 'custom', 'label' => 'Custom'")
->toContain('type="color"') ->toContain('type="color"')
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0') ->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'") ->toContain("this.theme === 'custom'")
->and($accountMenu) ->and($accountMenu)
->toContain("['value' => 'custom', 'label' => 'Custom']") ->toContain("['value' => 'custom', 'label' => 'Custom']")
->toContain('aria-label="Custom theme color"') ->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') ->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->and($layout) ->and($layout)
->toContain("t === 'custom'") ->toContain("t === 'custom'")

View file

@ -53,6 +53,17 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment
], $overrides)); ], $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 () { it('hides the deployments section when there is nothing to show', function () {
Livewire::test(ActiveDeployments::class) Livewire::test(ActiveDeployments::class)
->assertDontSee('Active and recent deployment activity') ->assertDontSee('Active and recent deployment activity')

View 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())');
});

View file

@ -11,11 +11,15 @@
test('deployment logs use light surfaces in light mode and dark surfaces in dark mode', function () { 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')); $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) expect($view)
->toContain('bg-white text-neutral-800') ->toContain('bg-white text-neutral-800')
->toContain('dark:bg-[#0d0d0d] dark:text-neutral-100') ->toContain('dark:bg-log dark:text-neutral-100')
->toContain('border-neutral-200 shadow-sm dark:border-neutral-800') ->toContain('border-neutral-200 shadow-sm dark:border-coolgray-200')
->toContain('border-neutral-200! bg-white!') ->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');
}); });

View file

@ -117,6 +117,8 @@
->toContain('.logs-viewer-actions') ->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions') ->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section') ->toContain('.logs-settings-section')
->toContain('padding: 0.5rem 0.75rem 2rem;')
->toContain('padding: 0.5rem 1rem 2rem;')
->toContain('flex-direction: column') ->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)'); ->toContain('@media (min-width: 640px)');

View file

@ -7,10 +7,31 @@
expect($baseLayout)->toContain('<x-icon-tooltip />') expect($baseLayout)->toContain('<x-icon-tooltip />')
->and($tooltip) ->and($tooltip)
->toContain("closest('button, a, [data-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.matches('[data-icon-tooltip-ignore]')")
->toContain("target.removeAttribute('title')") ->toContain("target.removeAttribute('title')")
->toContain('role="tooltip"') ->toContain('role="tooltip"')
->toContain('aria-label') ->toContain('aria-label')
->toContain('fixed z-[100]'); ->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'");
});

View file

@ -19,7 +19,7 @@
expect($view) expect($view)
->not->toContain('Create mode (server-rendered path)') ->not->toContain('Create mode (server-rendered path)')
->not->toContain('!$wire.isCreateMode') ->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\">") ->not->toContain("<!-- Command palette -->\n <template x-teleport=\"body\">")
->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length") ->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length")
->toContain('x-for="(result, index) in searchResults"') ->toContain('x-for="(result, index) in searchResults"')
@ -32,3 +32,29 @@
expect($view) expect($view)
->toContain('filter(item => item.offsetParent !== null)'); ->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');
});

View file

@ -10,7 +10,7 @@
expect($utilities) expect($utilities)
->toContain('@utility button-highlighted') ->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) ->and($appStyles)
->toContain('button[isHighlighted]:not(:disabled)') ->toContain('button[isHighlighted]:not(:disabled)')
->toContain('@apply button-highlighted;') ->toContain('@apply button-highlighted;')

View file

@ -1,25 +1,20 @@
<?php <?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')); $utilities = file_get_contents(resource_path('css/utilities.css'));
$appCss = file_get_contents(resource_path('css/app.css')); $appCss = file_get_contents(resource_path('css/app.css'));
$loading = file_get_contents(resource_path('views/components/loading.blade.php')); $loading = file_get_contents(resource_path('views/components/loading.blade.php'));
$pageLoading = file_get_contents(resource_path('views/components/page-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) expect($utilities)
->toContain('@utility loading-indicator') ->toContain('@utility loading-indicator')
->toContain('@apply text-coollabs dark:text-warning;') ->toContain('@apply text-coollabs dark:text-coollabs;')
->and($loading)->toContain('loading-indicator') ->and($loading)->toContain('loading-indicator')
->and($pageLoading)->toContain('loading-indicator') ->and($pageLoading)->toContain('loading-indicator')
->and($appCss)->toContain('.dark .animate-spin') ->and($appCss)->toContain('.dark .animate-spin')
->toContain('color: var(--color-warning) !important;') ->toContain('color: var(--color-coollabs) !important;')
->toContain('.dark #nprogress .bar') ->toContain('.dark #nprogress .bar')
->toContain('background: var(--color-warning) !important;') ->toContain('background: var(--color-coollabs) !important;')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/'); ->not->toContain('color: var(--color-warning) !important;');
}); });
test('livewire navigation progress bar uses coollabs purple', function () { test('livewire navigation progress bar uses coollabs purple', function () {

View 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"');
});

View file

@ -155,6 +155,19 @@
->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s'); ->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 () { 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')); $view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));

View file

@ -177,19 +177,20 @@
->not->toContain("'label' => 'Runtime'") ->not->toContain("'label' => 'Runtime'")
->and($sidebar) ->and($sidebar)
->toContain("'label' => 'Terminal'") ->toContain("'label' => 'Terminal'")
->toContain("'label' => 'Deployment'") ->toContain("'label' => 'Deployment Logs'")
->toContain("'label' => 'Runtime'") ->toContain("'label' => 'Runtime Logs'")
->toContain("'Logs' => ['Deployment', 'Runtime']") ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'"); ->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
}); });
it('groups application automation pages separately from build and deploy', function () { it('groups application automation pages separately from build and deploy', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar) expect($sidebar)
->toContain("'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck']") ->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments']") ->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'"); ->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
}); });
it('centers the rollback image loading state across the card', function () { 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 () { it('uses a distinct runtime log icon in the sidebar', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); $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 () { it('shows deployment history above the selected deployment logs', function () {

View file

@ -47,6 +47,25 @@
->toContain('Enter</kbd>'); ->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 () { 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')); $unsavedBar = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
$popup = file_get_contents(resource_path('views/components/popup-small.blade.php')); $popup = file_get_contents(resource_path('views/components/popup-small.blade.php'));

View file

@ -18,3 +18,42 @@
->not->toContain('new-server-token-') ->not->toContain('new-server-token-')
->not->toContain('tokenProviderName'); ->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");
});

View file

@ -6,7 +6,11 @@
expect($view) expect($view)
->toContain('class="flex items-end gap-3"') ->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.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('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."'); ->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
}); });

View file

@ -8,17 +8,21 @@
resource_path('views/livewire/project/resource/index.blade.php'), resource_path('views/livewire/project/resource/index.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'), resource_path('views/livewire/project/service/configuration.blade.php'),
resource_path('views/livewire/tags/show.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)); ])->map(fn (string $path): string => file_get_contents($path));
$utilities = file_get_contents(resource_path('css/utilities.css')); $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, "viewMode === 'grid'")
&& str_contains($view, 'control-selected'))) && str_contains($view, 'control-selected')))
->toBeTrue() ->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($views->implode("\n"))->not->toContain('dark:bg-warning/15 dark:text-warning')
->and($utilities) ->and($utilities)
->toContain('@utility control-selected') ->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 () { test('server index does not expose server IP addresses', function () {

View 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>');
});

View file

@ -10,11 +10,11 @@
->and($databaseHeading)->not->toContain('<x-resource-heading-tabs') ->and($databaseHeading)->not->toContain('<x-resource-heading-tabs')
->and($serviceConfiguration) ->and($serviceConfiguration)
->toContain("['label' => 'Backups'") ->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'") ->toContain("['label' => 'Runtime Logs'")
->toContain("['label' => 'Terminal'") ->toContain("['label' => 'Terminal'")
->and($databaseSidebar) ->and($databaseSidebar)
->toContain("['label' => 'Backups'") ->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'") ->toContain("['label' => 'Runtime Logs'")
->toContain("['label' => 'Terminal'"); ->toContain("['label' => 'Terminal'");
}); });
@ -41,21 +41,38 @@
->and($database)->toContain('id="database-desktop-actions"'); ->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')); $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) expect($database)
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']") ->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck']")
->toContain("'Automation' => ['Webhooks', 'Healthcheck']") ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics']")
->toContain("'Logs' => ['Runtime']") ->toContain("'Deploy' => ['Servers']")
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']"); ->toContain("'Automation' => ['Webhooks', 'Backups', 'Import Backup']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone']");
expect($service) foreach ($serviceSidebars as $serviceSidebar) {
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups']") expect($serviceSidebar)
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks']") ->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage']")
->toContain("'Logs' => ['Runtime']") ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']"); ->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 () { it('shows the database sidebar on backup pages', function () {
@ -155,6 +172,6 @@
->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)") ->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"') ->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
->and($sidebar) ->and($sidebar)
->toContain("'Logs' => ['Runtime']") ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']"); ->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
}); });

View file

@ -2,8 +2,10 @@
use App\Livewire\Storage\Resources as StorageResources; use App\Livewire\Storage\Resources as StorageResources;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
@ -75,6 +77,57 @@
}); });
describe('Storage/Resources team-scoped backup access', function () { 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 () { test('disableS3 on other team backup throws and leaves row unchanged', function () {
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->call('disableS3', $this->backupB->id)) ->call('disableS3', $this->backupB->id))

View file

@ -19,6 +19,18 @@
->toContain('{{ $userEmail }}'); ->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 () { 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')); $menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));

View 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');
});

View 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');
});

View file

@ -10,7 +10,7 @@
"version": "1.0.14" "version": "1.0.14"
}, },
"realtime": { "realtime": {
"version": "1.0.16" "version": "1.0.17"
}, },
"sentinel": { "sentinel": {
"version": "0.0.22" "version": "0.0.22"

View file

@ -16,6 +16,11 @@ export default defineConfig(({ mode }) => {
viteHost viteHost
).trim(); ).trim();
const vitePort = Number(process.env.VITE_PORT || env.VITE_PORT || 5173); const vitePort = Number(process.env.VITE_PORT || env.VITE_PORT || 5173);
const viteProtocol = (
process.env.VITE_PROTOCOL ||
env.VITE_PROTOCOL ||
"http"
).trim();
return { return {
resolve: { resolve: {
@ -34,10 +39,11 @@ export default defineConfig(({ mode }) => {
allowedHosts: true, allowedHosts: true,
// App (:8000) and Vite (:5173) are different origins; allow any host in dev // App (:8000) and Vite (:5173) are different origins; allow any host in dev
cors: true, cors: true,
origin: `http://${viteHost}:${vitePort}`, origin: `${viteProtocol}://${viteHost}:${vitePort}`,
hmr: { hmr: {
host: viteHmrHost, host: viteHmrHost,
clientPort: vitePort, clientPort: vitePort,
protocol: viteProtocol === "https" ? "wss" : "ws",
}, },
}, },
plugins: [ plugins: [