diff --git a/app/Actions/Server/ClearServiceLog.php b/app/Actions/Server/ClearServiceLog.php new file mode 100644 index 000000000..c77d5505f --- /dev/null +++ b/app/Actions/Server/ClearServiceLog.php @@ -0,0 +1,34 @@ + $input + * + * @throws Throwable + * @throws ValidationException + */ + public function run(Server $server, array $input): void + { + $data = Validator::make($input, [ + 'key' => ['required', 'string', 'max:200'], + ])->validate(); + + $log = app(GetServiceLogs::class)->resolve($server, $data['key']); + abort_if($log === null, 404); + + if ($log->source !== ServiceLog::SOURCE_FILE) { + throw ValidationException::withMessages(['key' => 'Journal logs cannot be cleared.']); + } + + $server->os()->clearFile($log->target); + } +} diff --git a/app/Actions/Server/DownloadServiceLog.php b/app/Actions/Server/DownloadServiceLog.php new file mode 100644 index 000000000..c6a2aed49 --- /dev/null +++ b/app/Actions/Server/DownloadServiceLog.php @@ -0,0 +1,76 @@ + $input + * + * @throws Throwable + * @throws ValidationException + */ + public function run(Server $server, array $input): StreamedResponse + { + $data = Validator::make($input, [ + 'key' => ['required', 'string', 'max:200'], + ])->validate(); + + $log = app(GetServiceLogs::class)->resolve($server, $data['key']); + abort_if($log === null, 404); + + $downloadName = $log->source === ServiceLog::SOURCE_JOURNAL + ? Str::slug($log->key).'.log' + : str($log->target)->afterLast('/')->toString(); + + $tmpName = $server->id.'-'.now()->timestamp.'-'.Str::random(8).'-'.Str::slug($log->key).'.log'; + $tmpPath = Storage::disk('local')->path($tmpName); + + $remoteTmp = '/tmp/vito-'.Str::random(12).'.log'; + try { + if ($log->source === ServiceLog::SOURCE_JOURNAL) { + $server->ssh()->exec(view('ssh.os.journal-dump', [ + 'unit' => $log->target, + 'path' => $remoteTmp, + ])); + } else { + $output = $server->ssh()->exec(view('ssh.os.copy-as-user', [ + 'source' => $log->target, + 'dest' => $remoteTmp, + ])); + abort_if( + trim($output) === OS::FILE_NOT_FOUND, + 404, + 'The log file does not exist on the server.' + ); + } + $server->ssh()->download($tmpPath, $remoteTmp); + } finally { + try { + $server->os()->deleteFile($remoteTmp); + } catch (Throwable) { + } + } + + dispatch(function () use ($tmpPath): void { + if (File::exists($tmpPath)) { + File::delete($tmpPath); + } + }) + ->delay(now()->addMinutes(5)) + ->onQueue('default'); + + return Storage::disk('local')->download($tmpName, $downloadName); + } +} diff --git a/app/Actions/Server/GetServiceLogs.php b/app/Actions/Server/GetServiceLogs.php new file mode 100644 index 000000000..6b64f07a3 --- /dev/null +++ b/app/Actions/Server/GetServiceLogs.php @@ -0,0 +1,57 @@ + + */ + public function handle(Server $server): array + { + $logs = []; + + $server->loadMissing('sites'); + + $services = $server->services() + ->where('status', ServiceStatus::READY) + ->get(); + + foreach ($services as $service) { + $service->setRelation('server', $server); + $handler = $service->handler(); + if (! $handler instanceof HasLogs) { + continue; + } + foreach ($handler->logs() as $log) { + $logs[] = $log; + } + } + + $logs[] = new ServiceLog( + key: 'system:sshd', + serviceLabel: 'System', + label: 'SSH daemon journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'ssh.service', + ); + + return $logs; + } + + public function resolve(Server $server, string $key): ?ServiceLog + { + foreach ($this->handle($server) as $log) { + if ($log->key === $key) { + return $log; + } + } + + return null; + } +} diff --git a/app/Actions/Server/ReadServiceLog.php b/app/Actions/Server/ReadServiceLog.php new file mode 100644 index 000000000..1008c2593 --- /dev/null +++ b/app/Actions/Server/ReadServiceLog.php @@ -0,0 +1,64 @@ + $input + * @return array{content: string, display_target: string, source: string} + * + * @throws SSHError + * @throws ValidationException + */ + public function run(Server $server, array $input): array + { + $data = Validator::make($input, [ + 'key' => ['required', 'string', 'max:200'], + 'lines' => ['nullable', 'integer', 'min:50', 'max:2000'], + 'search' => ['nullable', 'string', 'max:200', 'regex:/^[^\x00\r\n]*$/'], + ])->validate(); + + $log = app(GetServiceLogs::class)->resolve($server, $data['key']); + abort_if($log === null, 404); + + $lines = (int) ($data['lines'] ?? 100); + $search = $data['search'] ?? null; + $hasSearch = $search !== null && $search !== ''; + + if ($log->source === ServiceLog::SOURCE_JOURNAL) { + $content = $server->ssh()->exec(view('ssh.os.journal-read', [ + 'unit' => $log->target, + 'lines' => $lines, + 'search' => $hasSearch ? $search : null, + ])); + } elseif ($hasSearch) { + $content = $server->ssh()->exec(view('ssh.os.grep', [ + 'path' => $log->target, + 'term' => $search, + 'lines' => $lines, + ])); + } else { + $content = $server->os()->tail($log->target, $lines); + } + + abort_if( + $log->source === ServiceLog::SOURCE_FILE && trim($content) === OS::FILE_NOT_FOUND, + 404, + 'The log file does not exist on the server.' + ); + + return [ + 'content' => $content, + 'display_target' => $log->displayTarget(), + 'source' => $log->source, + ]; + } +} diff --git a/app/DTOs/ServiceLog.php b/app/DTOs/ServiceLog.php new file mode 100644 index 000000000..32fdeac9d --- /dev/null +++ b/app/DTOs/ServiceLog.php @@ -0,0 +1,27 @@ +source === self::SOURCE_JOURNAL) { + return 'journal: '.$this->target; + } + + return $this->target; + } +} diff --git a/app/Http/Controllers/ServerLogController.php b/app/Http/Controllers/ServerLogController.php index 39acbe2e0..9bf626193 100644 --- a/app/Http/Controllers/ServerLogController.php +++ b/app/Http/Controllers/ServerLogController.php @@ -2,13 +2,19 @@ namespace App\Http\Controllers; +use App\Actions\Server\ClearServiceLog; +use App\Actions\Server\DownloadServiceLog; +use App\Actions\Server\GetServiceLogs; +use App\Actions\Server\ReadServiceLog; use App\Actions\ServerLog\CreateLog; use App\Actions\ServerLog\UpdateLog; +use App\DTOs\ServiceLog; use App\Helpers\QueryBuilder; use App\Http\Resources\ServerLogResource; use App\Models\Server; use App\Models\ServerLog; use App\Models\Site; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; @@ -50,7 +56,7 @@ public function remote(Server $server): Response $this->authorize('viewAny', [ServerLog::class, $server]); return Inertia::render('server-logs/index', [ - 'title' => 'Remote logs', + 'title' => 'Custom logs', 'logs' => ServerLogResource::collection($server->logs()->where('is_remote', 1)->latest()->simplePaginate(config('web.pagination_size'))), 'remote' => true, ]); @@ -69,6 +75,56 @@ public function json(Request $request, Server $server, ?Site $site = null): Reso return ServerLogResource::collection($logs); } + #[Get('/services', name: 'logs.services')] + public function services(Server $server): Response + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + $catalogue = array_map( + fn (ServiceLog $log): array => [ + 'key' => $log->key, + 'service_label' => $log->serviceLabel, + 'label' => $log->label, + 'display_target' => $log->displayTarget(), + 'source' => $log->source, + ], + app(GetServiceLogs::class)->handle($server), + ); + + return Inertia::render('server-logs/services', [ + 'title' => 'Service logs', + 'catalogue' => $catalogue, + ]); + } + + #[Post('/services/read', name: 'logs.services.read')] + public function readServiceLog(Request $request, Server $server): JsonResponse + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + return response()->json( + app(ReadServiceLog::class)->run($server, $request->only('key', 'lines', 'search')), + ); + } + + #[Get('/services/download', name: 'logs.services.download')] + public function downloadServiceLog(Request $request, Server $server): StreamedResponse + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + return app(DownloadServiceLog::class)->run($server, $request->only('key')); + } + + #[Post('/services/clear', name: 'logs.services.clear')] + public function clearServiceLog(Request $request, Server $server): RedirectResponse + { + $this->authorize('deleteMany', [ServerLog::class, $server]); + + app(ClearServiceLog::class)->run($server, $request->only('key')); + + return back()->with('success', 'Log cleared successfully'); + } + #[Get('/{log}', name: 'logs.show')] public function show(Server $server, ServerLog $log): string { diff --git a/app/Models/ServerLog.php b/app/Models/ServerLog.php index 915f2bb0c..3ac190b2d 100755 --- a/app/Models/ServerLog.php +++ b/app/Models/ServerLog.php @@ -5,6 +5,7 @@ use App\DTOs\SocketEventDTO; use App\Events\SocketEvent; use App\Http\Resources\ServerLogResource; +use App\SSH\OS\OS; use Database\Factories\ServerLogFactory; use Exception; use Illuminate\Database\Eloquent\Builder; @@ -169,7 +170,9 @@ public function write(string $buf): void public function getContent(?int $lines = null): ?string { if ($this->is_remote) { - return $this->server->os()->tail($this->name, $lines ?? 150); + $content = $this->server->os()->tail($this->name, $lines ?? 150); + + return trim($content) === OS::FILE_NOT_FOUND ? "Log file doesn't exist or is empty!" : $content; } if (Storage::disk($this->disk)->exists($this->name)) { diff --git a/app/SSH/OS/OS.php b/app/SSH/OS/OS.php index ee6049c67..b8a710c58 100644 --- a/app/SSH/OS/OS.php +++ b/app/SSH/OS/OS.php @@ -10,6 +10,8 @@ class OS { + public const FILE_NOT_FOUND = 'VITO_NO_FILE'; + private const SHELL_IDENTIFIER = '/^[A-Za-z_][A-Za-z0-9_]*$/'; public function __construct(protected Server $server) {} diff --git a/app/Services/Database/Mariadb.php b/app/Services/Database/Mariadb.php index 1d440bf88..237c21971 100644 --- a/app/Services/Database/Mariadb.php +++ b/app/Services/Database/Mariadb.php @@ -2,7 +2,10 @@ namespace App\Services\Database; -class Mariadb extends AbstractDatabase +use App\DTOs\ServiceLog; +use App\Services\HasLogs; + +class Mariadb extends AbstractDatabase implements HasLogs { protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; @@ -37,4 +40,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'mariadb:journal', + serviceLabel: 'MariaDB', + label: 'Service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'mariadb.service', + ), + ]; + } } diff --git a/app/Services/Database/Mysql.php b/app/Services/Database/Mysql.php index 5bff4a2bd..ef530cc5f 100755 --- a/app/Services/Database/Mysql.php +++ b/app/Services/Database/Mysql.php @@ -2,7 +2,10 @@ namespace App\Services\Database; -class Mysql extends AbstractDatabase +use App\DTOs\ServiceLog; +use App\Services\HasLogs; + +class Mysql extends AbstractDatabase implements HasLogs { protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; @@ -38,4 +41,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'mysql:journal', + serviceLabel: 'MySQL', + label: 'Service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'mysql.service', + ), + ]; + } } diff --git a/app/Services/Database/Postgresql.php b/app/Services/Database/Postgresql.php index 14eeec7f6..94fbd0688 100644 --- a/app/Services/Database/Postgresql.php +++ b/app/Services/Database/Postgresql.php @@ -2,7 +2,10 @@ namespace App\Services\Database; -class Postgresql extends AbstractDatabase +use App\DTOs\ServiceLog; +use App\Services\HasLogs; + +class Postgresql extends AbstractDatabase implements HasLogs { protected array $systemDbs = ['template0', 'template1', 'postgres']; @@ -42,4 +45,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'postgresql:journal', + serviceLabel: 'PostgreSQL', + label: 'Service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'postgresql.service', + ), + ]; + } } diff --git a/app/Services/Firewall/Ufw.php b/app/Services/Firewall/Ufw.php index 8892e5c60..e421edc9b 100755 --- a/app/Services/Firewall/Ufw.php +++ b/app/Services/Firewall/Ufw.php @@ -2,10 +2,12 @@ namespace App\Services\Firewall; +use App\DTOs\ServiceLog; use App\Enums\FirewallRuleStatus; use App\Exceptions\SSHError; +use App\Services\HasLogs; -class Ufw extends AbstractFirewall +class Ufw extends AbstractFirewall implements HasLogs { public static function id(): string { @@ -68,4 +70,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'ufw:general', + serviceLabel: 'UFW', + label: 'General log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/ufw.log', + ), + ]; + } } diff --git a/app/Services/HasLogs.php b/app/Services/HasLogs.php new file mode 100644 index 000000000..3051dffe3 --- /dev/null +++ b/app/Services/HasLogs.php @@ -0,0 +1,13 @@ + + */ + public function logs(): array; +} diff --git a/app/Services/PHP/PHP.php b/app/Services/PHP/PHP.php index 5f050ad0b..a958ab9a1 100644 --- a/app/Services/PHP/PHP.php +++ b/app/Services/PHP/PHP.php @@ -2,14 +2,16 @@ namespace App\Services\PHP; +use App\DTOs\ServiceLog; use App\Exceptions\SSHCommandError; use App\Exceptions\SSHError; use App\Services\AbstractService; +use App\Services\HasLogs; use Closure; use Illuminate\Support\Str; use Illuminate\Validation\Rule; -class PHP extends AbstractService +class PHP extends AbstractService implements HasLogs { public static function id(): string { @@ -190,4 +192,49 @@ public function version(): string return trim($version); } + + public function logs(): array + { + $version = $this->service->version; + $serviceLabel = 'PHP '.$version; + + $logs = [ + new ServiceLog( + key: 'php:'.$version.':fpm-journal', + serviceLabel: $serviceLabel, + label: 'FPM service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'php'.$version.'-fpm.service', + ), + ]; + + $sites = $this->service->server->relationLoaded('sites') + ? $this->service->server->sites + ->where('php_version', $version) + ->sortBy('id') + : $this->service->server->sites() + ->where('php_version', $version) + ->orderBy('id') + ->get(['id', 'domain', 'user']); + + /** @var array> $domainsByUser */ + $domainsByUser = []; + foreach ($sites as $site) { + $user = $site->user; + $domainsByUser[$user] = $domainsByUser[$user] ?? []; + $domainsByUser[$user][] = $site->domain; + } + + foreach ($domainsByUser as $user => $domains) { + $logs[] = new ServiceLog( + key: 'php:'.$version.':user:'.$user, + serviceLabel: $serviceLabel, + label: 'FPM pool '.$user.' ('.implode(', ', $domains).')', + source: ServiceLog::SOURCE_FILE, + target: '/home/'.$user.'/.logs/php_errors.log', + ); + } + + return $logs; + } } diff --git a/app/Services/ProcessManager/Supervisor.php b/app/Services/ProcessManager/Supervisor.php index d913150e8..b459132b8 100644 --- a/app/Services/ProcessManager/Supervisor.php +++ b/app/Services/ProcessManager/Supervisor.php @@ -2,10 +2,12 @@ namespace App\Services\ProcessManager; +use App\DTOs\ServiceLog; use App\Exceptions\SSHError; +use App\Services\HasLogs; use Throwable; -class Supervisor extends AbstractProcessManager +class Supervisor extends AbstractProcessManager implements HasLogs { public static function id(): string { @@ -197,4 +199,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'supervisor:general', + serviceLabel: 'Supervisor', + label: 'General log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/supervisor/supervisord.log', + ), + ]; + } } diff --git a/app/Services/Redis/Redis.php b/app/Services/Redis/Redis.php index 4a440a241..67ab69267 100644 --- a/app/Services/Redis/Redis.php +++ b/app/Services/Redis/Redis.php @@ -2,12 +2,14 @@ namespace App\Services\Redis; +use App\DTOs\ServiceLog; use App\Exceptions\ServiceInstallationFailed; use App\Exceptions\SSHError; use App\Services\AbstractService; +use App\Services\HasLogs; use Closure; -class Redis extends AbstractService +class Redis extends AbstractService implements HasLogs { public static function id(): string { @@ -74,4 +76,17 @@ public function version(): string { return $this->service->server->ssh()->exec('redis-server --version | awk \'{print $3}\' | cut -d= -f2', 'get-redis-version'); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'redis:journal', + serviceLabel: 'Redis', + label: 'Service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'redis-server.service', + ), + ]; + } } diff --git a/app/Services/Valkey/Valkey.php b/app/Services/Valkey/Valkey.php index f799f6c65..aac5d9d6a 100644 --- a/app/Services/Valkey/Valkey.php +++ b/app/Services/Valkey/Valkey.php @@ -2,12 +2,14 @@ namespace App\Services\Valkey; +use App\DTOs\ServiceLog; use App\Exceptions\ServiceInstallationFailed; use App\Exceptions\SSHError; use App\Services\AbstractService; +use App\Services\HasLogs; use Closure; -class Valkey extends AbstractService +class Valkey extends AbstractService implements HasLogs { public static function id(): string { @@ -74,4 +76,17 @@ public function version(): string { return $this->service->server->ssh()->exec("valkey-server --version | grep -oP 'v=\\K[0-9.]+'", 'get-valkey-version'); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'valkey:journal', + serviceLabel: 'Valkey', + label: 'Service journal', + source: ServiceLog::SOURCE_JOURNAL, + target: 'valkey-server.service', + ), + ]; + } } diff --git a/app/Services/Webserver/Caddy.php b/app/Services/Webserver/Caddy.php index ff474338a..971611fe9 100755 --- a/app/Services/Webserver/Caddy.php +++ b/app/Services/Webserver/Caddy.php @@ -3,14 +3,16 @@ namespace App\Services\Webserver; use App\Actions\Webserver\GenerateCaddyConfig; +use App\DTOs\ServiceLog; use App\Enums\SslMethod; use App\Exceptions\SSHError; use App\Exceptions\SSLCreationException; use App\Models\Site; use App\Models\Ssl; +use App\Services\HasLogs; use Throwable; -class Caddy extends AbstractWebserver +class Caddy extends AbstractWebserver implements HasLogs { public static function id(): string { @@ -265,4 +267,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'caddy:error', + serviceLabel: 'Caddy', + label: 'Error log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/caddy/errors.log', + ), + ]; + } } diff --git a/app/Services/Webserver/Nginx.php b/app/Services/Webserver/Nginx.php index f6456e40a..e22ebdb5b 100755 --- a/app/Services/Webserver/Nginx.php +++ b/app/Services/Webserver/Nginx.php @@ -3,13 +3,15 @@ namespace App\Services\Webserver; use App\Actions\Webserver\GenerateNginxConfig; +use App\DTOs\ServiceLog; use App\Exceptions\SSHError; use App\Exceptions\SSLCreationException; use App\Models\Site; use App\Models\Ssl; +use App\Services\HasLogs; use Throwable; -class Nginx extends AbstractWebserver +class Nginx extends AbstractWebserver implements HasLogs { public static function id(): string { @@ -267,4 +269,24 @@ public function version(): string return str(trim($version))->before(' '); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'nginx:error', + serviceLabel: 'NGINX', + label: 'Error log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/nginx/error.log', + ), + new ServiceLog( + key: 'nginx:access', + serviceLabel: 'NGINX', + label: 'Access log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/nginx/access.log', + ), + ]; + } } diff --git a/resources/js/components/ui/combobox.tsx b/resources/js/components/ui/combobox.tsx index 57dbfb3bf..1739baa18 100644 --- a/resources/js/components/ui/combobox.tsx +++ b/resources/js/components/ui/combobox.tsx @@ -11,21 +11,24 @@ export function Combobox({ value, searchText = 'Search items...', noneFoundText = 'No items found.', + placeholder = '', onValueChange, }: { items: { value: string; label: string }[]; value: string; searchText?: string; noneFoundText?: string; + placeholder?: string; onValueChange: (value: string) => void; }) { const [open, setOpen] = React.useState(false); + const selectedLabel = value ? items.find((item) => item.value === value)?.label : ''; return ( diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index a0f6c06bb..01f624c46 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -215,7 +215,13 @@ export default function ServerLayout({ children }: { children: ReactNode }) { icon: LogsIcon, }, { - title: 'Remote logs', + title: 'Service logs', + href: route('logs.services', { server: page.props.server.id }), + onlyActivePath: route('logs.services', { server: page.props.server.id }), + icon: CogIcon, + }, + { + title: 'Custom logs', href: route('logs.remote', { server: page.props.server.id }), onlyActivePath: route('logs.remote', { server: page.props.server.id }), icon: CloudIcon, diff --git a/resources/js/pages/server-logs/services.tsx b/resources/js/pages/server-logs/services.tsx new file mode 100644 index 000000000..ef1728c71 --- /dev/null +++ b/resources/js/pages/server-logs/services.tsx @@ -0,0 +1,352 @@ +import { Head, Link, useForm, usePage } from '@inertiajs/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; +import { BookOpenIcon, CogIcon, DownloadIcon, EraserIcon, LoaderCircleIcon, RefreshCwIcon } from 'lucide-react'; +import { Server } from '@/types/server'; +import ServerLayout from '@/layouts/server/layout'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Combobox } from '@/components/ui/combobox'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import FormSuccessful from '@/components/form-successful'; + +type CatalogueItem = { + key: string; + service_label: string; + label: string; + display_target: string; + source: 'file' | 'journal'; +}; + +const LINE_OPTIONS = ['100', '200', '500', '1000', '2000'] as const; +type LineOption = (typeof LINE_OPTIONS)[number]; + +export default function ServiceLogs() { + const page = usePage<{ + title: string; + server: Server; + catalogue: CatalogueItem[]; + }>(); + + const { catalogue, server } = page.props; + + const itemsByKey = useMemo(() => Object.fromEntries(catalogue.map((c) => [c.key, c])), [catalogue]); + + const [selectedKey, setSelectedKey] = useState(''); + const [lines, setLines] = useState('100'); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [content, setContent] = useState(''); + const [displayTarget, setDisplayTarget] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const selected = selectedKey ? itemsByKey[selectedKey] : undefined; + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(t); + }, [search]); + + const fetchLog = useCallback(() => { + if (!selectedKey) return; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsLoading(true); + setError(null); + setContent(''); + setDisplayTarget(''); + + axios + .post( + route('logs.services.read', { server: server.id }), + { key: selectedKey, lines: Number(lines), search: debouncedSearch || null }, + { signal: controller.signal }, + ) + .then((response) => { + if (controller.signal.aborted) return; + setContent(response.data.content ?? ''); + setDisplayTarget(response.data.display_target ?? ''); + }) + .catch((err: unknown) => { + if (axios.isCancel(err)) return; + if (axios.isAxiosError(err)) { + const msg = err.response?.data?.message || err.response?.data?.error || err.message; + setError(msg || 'Failed to read log'); + } else { + setError('Failed to read log'); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsLoading(false); + } + }); + }, [selectedKey, lines, debouncedSearch, server.id]); + + useEffect(() => { + fetchLog(); + return () => abortRef.current?.abort(); + }, [fetchLog]); + + const downloadLog = useCallback(async () => { + if (!selectedKey || isDownloading) return; + setIsDownloading(true); + try { + const response = await axios.get(route('logs.services.download', { server: server.id, key: selectedKey }), { + responseType: 'blob', + }); + + const disposition = response.headers['content-disposition'] as string | undefined; + const match = disposition?.match(/filename="?([^";]+)"?/); + const filename = match?.[1] ?? `${selectedKey}.log`; + + const url = URL.createObjectURL(response.data as Blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (err) { + if (axios.isAxiosError(err)) { + let msg = err.message; + const data = err.response?.data; + if (data instanceof Blob) { + const text = await data.text(); + try { + const parsed = JSON.parse(text); + msg = parsed.message || parsed.error || msg; + } catch { + msg = text || msg; + } + } else if (data?.message || data?.error) { + msg = data.message || data.error; + } + setError(msg || 'Download failed'); + } else { + setError('Download failed'); + } + } finally { + setIsDownloading(false); + } + }, [selectedKey, isDownloading, server.id]); + + const comboItems = useMemo( + () => + catalogue.map((c) => ({ + value: c.key, + label: `[${c.service_label}] ${c.label} (${c.display_target})`, + })), + [catalogue], + ); + + return ( + + + + + + + + + + + + {catalogue.length === 0 ? ( + + + No services with logs are installed on this server yet.{' '} + + Manage services + + . + + + ) : ( + + +
+
+
+ setSearch(e.target.value)} + className="min-w-0 flex-1 md:max-w-xs" + disabled={!selectedKey} + aria-label="Search log contents" + /> + + + + {selected && selected.source === 'file' ? ( + + ) : ( + + )} +
+
+ + +
+ {displayTarget || (selectedKey ? ' ' : 'No log selected')} +
+ +
+
+ )} +
+
+ ); +} + +function LogViewer({ + isLoading, + error, + hasSelection, + content, + searchActive, +}: { + isLoading: boolean; + error: string | null; + hasSelection: boolean; + content: string; + searchActive: boolean; +}) { + const showCentered = !hasSelection || (hasSelection && isLoading) || (hasSelection && !error && content === ''); + + if (showCentered || (hasSelection && error)) { + return ( +
+ {!hasSelection && Pick a log from the dropdown above to view its contents.} + {hasSelection && isLoading && ( +
+ + Loading log… +
+ )} + {hasSelection && !isLoading && error && Error: {error}} + {hasSelection && !isLoading && !error && content === '' && ( + {searchActive ? 'No matches found.' : 'Log is empty.'} + )} +
+ ); + } + + return ( + +
{content}
+ + +
+ ); +} + +function ClearButton({ serverId, logKey, target, onCleared }: { serverId: number; logKey: string; target: string; onCleared: () => void }) { + const [open, setOpen] = useState(false); + const form = useForm({ key: logKey }); + + const submit = () => { + form.post(route('logs.services.clear', { server: serverId }), { + preserveScroll: true, + onSuccess: () => { + setOpen(false); + onCleared(); + }, + }); + }; + + return ( + + + + + + + Clear log + Clear log file contents + +
+

+ Are you sure you want to clear {target}? +

+

+ This truncates the file contents but preserves the file itself, its ownership and permissions. +

+
+ + + + + + +
+
+ ); +} diff --git a/resources/views/ssh/os/copy-as-user.blade.php b/resources/views/ssh/os/copy-as-user.blade.php new file mode 100644 index 000000000..4d2e6e7a1 --- /dev/null +++ b/resources/views/ssh/os/copy-as-user.blade.php @@ -0,0 +1,3 @@ +if ! sudo test -f {!! escapeshellarg($source) !!}; then echo VITO_NO_FILE; exit 0; fi +sudo cat {!! escapeshellarg($source) !!} > {!! escapeshellarg($dest) !!} +chmod 600 {!! escapeshellarg($dest) !!} diff --git a/resources/views/ssh/os/grep.blade.php b/resources/views/ssh/os/grep.blade.php new file mode 100644 index 000000000..66b379bc3 --- /dev/null +++ b/resources/views/ssh/os/grep.blade.php @@ -0,0 +1,2 @@ +if ! sudo test -f {!! escapeshellarg($path) !!}; then echo VITO_NO_FILE; exit 0; fi +sudo tail -n 100000 {!! escapeshellarg($path) !!} 2>/dev/null | grep -F -i -- {!! escapeshellarg($term) !!} | tail -n {{ (int) $lines }} diff --git a/resources/views/ssh/os/journal-dump.blade.php b/resources/views/ssh/os/journal-dump.blade.php new file mode 100644 index 000000000..f7f130b4f --- /dev/null +++ b/resources/views/ssh/os/journal-dump.blade.php @@ -0,0 +1,2 @@ +sudo journalctl -u {!! escapeshellarg($unit) !!} --no-pager --output=short-iso 2>/dev/null > {!! escapeshellarg($path) !!} +chmod 600 {!! escapeshellarg($path) !!} diff --git a/resources/views/ssh/os/journal-read.blade.php b/resources/views/ssh/os/journal-read.blade.php new file mode 100644 index 000000000..95bce849a --- /dev/null +++ b/resources/views/ssh/os/journal-read.blade.php @@ -0,0 +1,5 @@ +@if(! empty($search)) +sudo journalctl -u {!! escapeshellarg($unit) !!} --since '7 days ago' --no-pager --output=short-iso 2>/dev/null | grep -F -i -- {!! escapeshellarg($search) !!} | tail -n {{ (int) $lines }} +@else +sudo journalctl -u {!! escapeshellarg($unit) !!} -n {{ (int) $lines }} --no-pager --output=short-iso 2>/dev/null +@endif diff --git a/resources/views/ssh/os/tail.blade.php b/resources/views/ssh/os/tail.blade.php index 42532f7e2..64f893d0b 100644 --- a/resources/views/ssh/os/tail.blade.php +++ b/resources/views/ssh/os/tail.blade.php @@ -1 +1,2 @@ +if ! sudo test -f {{ $path }}; then echo VITO_NO_FILE; exit 0; fi sudo tail -n {{ $lines }} {{ $path }} diff --git a/tests/Feature/ServiceLogsTest.php b/tests/Feature/ServiceLogsTest.php new file mode 100644 index 000000000..35077a103 --- /dev/null +++ b/tests/Feature/ServiceLogsTest.php @@ -0,0 +1,306 @@ +actingAs($this->user); + + $response = $this->get(route('logs.services', $this->server)); + + $response->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('server-logs/services') + ->where('title', 'Service logs') + ->has('catalogue') + ); + + $catalogue = $response->viewData('page')['props']['catalogue']; + $keys = array_column($catalogue, 'key'); + + $this->assertContains('nginx:error', $keys); + $this->assertContains('nginx:access', $keys); + $this->assertContains('mysql:journal', $keys); + $this->assertContains('php:8.2:fpm-journal', $keys); + $this->assertContains('ufw:general', $keys); + $this->assertContains('supervisor:general', $keys); + $this->assertContains('redis:journal', $keys); + $this->assertContains('system:sshd', $keys); + $this->assertContains('php:8.2:user:vito', $keys); + } + + public function test_services_without_has_logs_are_skipped(): void + { + $this->actingAs($this->user); + + $response = $this->get(route('logs.services', $this->server)); + $catalogue = $response->viewData('page')['props']['catalogue']; + $serviceLabels = array_unique(array_column($catalogue, 'service_label')); + + $this->assertNotContains('Node.js', $serviceLabels); + $this->assertNotContains('NodeJS', $serviceLabels); + } + + public function test_read_with_unknown_key_returns_404(): void + { + $this->actingAs($this->user); + SSH::fake(); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nope:does-not-exist', + ])->assertNotFound(); + } + + public function test_read_happy_path_for_file_source(): void + { + $this->actingAs($this->user); + SSH::fake('nginx-error-output'); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + ]) + ->assertSuccessful() + ->assertJson([ + 'content' => 'nginx-error-output', + 'display_target' => '/var/log/nginx/error.log', + 'source' => 'file', + ]); + + SSH::assertExecutedContains('/var/log/nginx/error.log'); + } + + public function test_read_returns_404_when_file_missing(): void + { + $this->actingAs($this->user); + SSH::fake('VITO_NO_FILE'); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + ]) + ->assertNotFound() + ->assertJson(['message' => 'The log file does not exist on the server.']); + } + + public function test_read_journal_source_uses_journalctl(): void + { + $this->actingAs($this->user); + SSH::fake('mysql-journal-output'); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'mysql:journal', + ])->assertSuccessful(); + + SSH::assertExecutedContains("journalctl -u 'mysql.service'"); + } + + public function test_read_rejects_lines_above_max(): void + { + $this->actingAs($this->user); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + 'lines' => 3000, + ])->assertStatus(422); + } + + public function test_read_rejects_search_with_newline(): void + { + $this->actingAs($this->user); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + 'search' => "abc\ninjected", + ])->assertStatus(422); + } + + public function test_read_with_search_shellescapes_term(): void + { + $this->actingAs($this->user); + SSH::fake('match'); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + 'search' => "needle's quote", + ])->assertSuccessful(); + + SSH::assertExecutedContains("'needle'\\''s quote'"); + } + + public function test_clear_truncates_file_source(): void + { + $this->actingAs($this->user); + SSH::fake(); + + $this->post(route('logs.services.clear', $this->server), [ + 'key' => 'nginx:error', + ]) + ->assertRedirect() + ->assertSessionHas('success', 'Log cleared successfully'); + + SSH::assertExecutedContains('/var/log/nginx/error.log'); + } + + public function test_clear_rejects_journal_source(): void + { + $this->actingAs($this->user); + SSH::fake(); + + $this->postJson(route('logs.services.clear', $this->server), [ + 'key' => 'mysql:journal', + ])->assertStatus(422); + } + + public function test_download_invokes_ssh_download(): void + { + $this->actingAs($this->user); + Bus::fake(); + Queue::fake(); + Carbon::setTestNow(Carbon::create(2026, 1, 1, 12)); + Str::createRandomStringsUsing(fn (int $n): string => str_repeat('a', $n)); + SSH::fake(); + + $tmpName = $this->server->id.'-'.Carbon::now()->timestamp.'-aaaaaaaa-'.Str::slug('nginx:error').'.log'; + Storage::disk('local')->put($tmpName, 'pretend-downloaded-bytes'); + + try { + $this->get(route('logs.services.download', ['server' => $this->server, 'key' => 'nginx:error'])) + ->assertSuccessful(); + + SSH::assertExecutedContains("sudo cat '/var/log/nginx/error.log'"); + } finally { + Storage::disk('local')->delete($tmpName); + Str::createRandomStringsNormally(); + Carbon::setTestNow(); + } + } + + public function test_download_journal_source(): void + { + $this->actingAs($this->user); + Bus::fake(); + Queue::fake(); + Carbon::setTestNow(Carbon::create(2026, 1, 1, 12)); + Str::createRandomStringsUsing(fn (int $n): string => str_repeat('a', $n)); + SSH::fake(); + + $tmpName = $this->server->id.'-'.Carbon::now()->timestamp.'-aaaaaaaa-'.Str::slug('mysql:journal').'.log'; + Storage::disk('local')->put($tmpName, 'pretend-downloaded-bytes'); + + try { + $this->get(route('logs.services.download', ['server' => $this->server, 'key' => 'mysql:journal'])) + ->assertSuccessful(); + + SSH::assertExecutedContains("sudo journalctl -u 'mysql.service'"); + } finally { + Storage::disk('local')->delete($tmpName); + Str::createRandomStringsNormally(); + Carbon::setTestNow(); + } + } + + public function test_download_returns_404_when_file_missing(): void + { + $this->actingAs($this->user); + SSH::fake('VITO_NO_FILE'); + + $this->getJson(route('logs.services.download', ['server' => $this->server, 'key' => 'nginx:error'])) + ->assertNotFound() + ->assertJson(['message' => 'The log file does not exist on the server.']); + } + + public function test_unknown_key_on_download_returns_404(): void + { + $this->actingAs($this->user); + SSH::fake(); + + $this->get(route('logs.services.download', ['server' => $this->server, 'key' => 'nope'])) + ->assertNotFound(); + } + + public function test_multiple_sites_per_user_deduped(): void + { + $this->actingAs($this->user); + + /** @var SourceControl $sc */ + $sc = SourceControl::factory()->github()->create(); + Site::factory()->create([ + 'domain' => 'second.test', + 'server_id' => $this->server->id, + 'source_control_id' => $sc->id, + 'repository' => 'organization/repository', + 'path' => '/home/vito/second.test', + 'branch' => 'main', + 'php_version' => '8.2', + 'user' => 'vito', + ]); + + $response = $this->get(route('logs.services', $this->server)); + $catalogue = $response->viewData('page')['props']['catalogue']; + $userEntries = array_values(array_filter($catalogue, fn ($e) => $e['key'] === 'php:8.2:user:vito')); + + $this->assertCount(1, $userEntries); + $this->assertStringContainsString('vito.test', $userEntries[0]['label']); + $this->assertStringContainsString('second.test', $userEntries[0]['label']); + } + + public function test_unauthorized_user_cannot_view(): void + { + /** @var User $other */ + $other = User::factory()->create(); + $this->actingAs($other); + + $this->get(route('logs.services', $this->server))->assertForbidden(); + } + + public function test_unauthorized_user_cannot_clear(): void + { + /** @var User $other */ + $other = User::factory()->create(); + $this->actingAs($other); + SSH::fake(); + + $this->post(route('logs.services.clear', $this->server), [ + 'key' => 'nginx:error', + ])->assertForbidden(); + } + + public function test_unauthorized_user_cannot_read(): void + { + /** @var User $other */ + $other = User::factory()->create(); + $this->actingAs($other); + SSH::fake(); + + $this->postJson(route('logs.services.read', $this->server), [ + 'key' => 'nginx:error', + ])->assertForbidden(); + } + + public function test_unauthorized_user_cannot_download(): void + { + /** @var User $other */ + $other = User::factory()->create(); + $this->actingAs($other); + SSH::fake(); + + $this->get(route('logs.services.download', ['server' => $this->server, 'key' => 'nginx:error'])) + ->assertForbidden(); + } +}