Skip to content

Commit 37180b5

Browse files
[Feat] Service Logs Viewer (#1108)
* service logs * review fixes * copilot feedback + fixes * moved ServiceLog to DTOs * fixes * fix dodgy merge
1 parent 4ffbe95 commit 37180b5

28 files changed

Lines changed: 1218 additions & 14 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Actions\Server;
4+
5+
use App\DTOs\ServiceLog;
6+
use App\Models\Server;
7+
use Illuminate\Support\Facades\Validator;
8+
use Illuminate\Validation\ValidationException;
9+
use Throwable;
10+
11+
class ClearServiceLog
12+
{
13+
/**
14+
* @param array<string, mixed> $input
15+
*
16+
* @throws Throwable
17+
* @throws ValidationException
18+
*/
19+
public function run(Server $server, array $input): void
20+
{
21+
$data = Validator::make($input, [
22+
'key' => ['required', 'string', 'max:200'],
23+
])->validate();
24+
25+
$log = app(GetServiceLogs::class)->resolve($server, $data['key']);
26+
abort_if($log === null, 404);
27+
28+
if ($log->source !== ServiceLog::SOURCE_FILE) {
29+
throw ValidationException::withMessages(['key' => 'Journal logs cannot be cleared.']);
30+
}
31+
32+
$server->os()->clearFile($log->target);
33+
}
34+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace App\Actions\Server;
4+
5+
use App\DTOs\ServiceLog;
6+
use App\Models\Server;
7+
use App\SSH\OS\OS;
8+
use Illuminate\Support\Facades\File;
9+
use Illuminate\Support\Facades\Storage;
10+
use Illuminate\Support\Facades\Validator;
11+
use Illuminate\Support\Str;
12+
use Illuminate\Validation\ValidationException;
13+
use Symfony\Component\HttpFoundation\StreamedResponse;
14+
use Throwable;
15+
16+
class DownloadServiceLog
17+
{
18+
/**
19+
* @param array<string, mixed> $input
20+
*
21+
* @throws Throwable
22+
* @throws ValidationException
23+
*/
24+
public function run(Server $server, array $input): StreamedResponse
25+
{
26+
$data = Validator::make($input, [
27+
'key' => ['required', 'string', 'max:200'],
28+
])->validate();
29+
30+
$log = app(GetServiceLogs::class)->resolve($server, $data['key']);
31+
abort_if($log === null, 404);
32+
33+
$downloadName = $log->source === ServiceLog::SOURCE_JOURNAL
34+
? Str::slug($log->key).'.log'
35+
: str($log->target)->afterLast('/')->toString();
36+
37+
$tmpName = $server->id.'-'.now()->timestamp.'-'.Str::random(8).'-'.Str::slug($log->key).'.log';
38+
$tmpPath = Storage::disk('local')->path($tmpName);
39+
40+
$remoteTmp = '/tmp/vito-'.Str::random(12).'.log';
41+
try {
42+
if ($log->source === ServiceLog::SOURCE_JOURNAL) {
43+
$server->ssh()->exec(view('ssh.os.journal-dump', [
44+
'unit' => $log->target,
45+
'path' => $remoteTmp,
46+
]));
47+
} else {
48+
$output = $server->ssh()->exec(view('ssh.os.copy-as-user', [
49+
'source' => $log->target,
50+
'dest' => $remoteTmp,
51+
]));
52+
abort_if(
53+
trim($output) === OS::FILE_NOT_FOUND,
54+
404,
55+
'The log file does not exist on the server.'
56+
);
57+
}
58+
$server->ssh()->download($tmpPath, $remoteTmp);
59+
} finally {
60+
try {
61+
$server->os()->deleteFile($remoteTmp);
62+
} catch (Throwable) {
63+
}
64+
}
65+
66+
dispatch(function () use ($tmpPath): void {
67+
if (File::exists($tmpPath)) {
68+
File::delete($tmpPath);
69+
}
70+
})
71+
->delay(now()->addMinutes(5))
72+
->onQueue('default');
73+
74+
return Storage::disk('local')->download($tmpName, $downloadName);
75+
}
76+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Actions\Server;
4+
5+
use App\DTOs\ServiceLog;
6+
use App\Enums\ServiceStatus;
7+
use App\Models\Server;
8+
use App\Services\HasLogs;
9+
10+
class GetServiceLogs
11+
{
12+
/**
13+
* @return array<int, ServiceLog>
14+
*/
15+
public function handle(Server $server): array
16+
{
17+
$logs = [];
18+
19+
$server->loadMissing('sites');
20+
21+
$services = $server->services()
22+
->where('status', ServiceStatus::READY)
23+
->get();
24+
25+
foreach ($services as $service) {
26+
$service->setRelation('server', $server);
27+
$handler = $service->handler();
28+
if (! $handler instanceof HasLogs) {
29+
continue;
30+
}
31+
foreach ($handler->logs() as $log) {
32+
$logs[] = $log;
33+
}
34+
}
35+
36+
$logs[] = new ServiceLog(
37+
key: 'system:sshd',
38+
serviceLabel: 'System',
39+
label: 'SSH daemon journal',
40+
source: ServiceLog::SOURCE_JOURNAL,
41+
target: 'ssh.service',
42+
);
43+
44+
return $logs;
45+
}
46+
47+
public function resolve(Server $server, string $key): ?ServiceLog
48+
{
49+
foreach ($this->handle($server) as $log) {
50+
if ($log->key === $key) {
51+
return $log;
52+
}
53+
}
54+
55+
return null;
56+
}
57+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace App\Actions\Server;
4+
5+
use App\DTOs\ServiceLog;
6+
use App\Exceptions\SSHError;
7+
use App\Models\Server;
8+
use App\SSH\OS\OS;
9+
use Illuminate\Support\Facades\Validator;
10+
use Illuminate\Validation\ValidationException;
11+
12+
class ReadServiceLog
13+
{
14+
/**
15+
* @param array<string, mixed> $input
16+
* @return array{content: string, display_target: string, source: string}
17+
*
18+
* @throws SSHError
19+
* @throws ValidationException
20+
*/
21+
public function run(Server $server, array $input): array
22+
{
23+
$data = Validator::make($input, [
24+
'key' => ['required', 'string', 'max:200'],
25+
'lines' => ['nullable', 'integer', 'min:50', 'max:2000'],
26+
'search' => ['nullable', 'string', 'max:200', 'regex:/^[^\x00\r\n]*$/'],
27+
])->validate();
28+
29+
$log = app(GetServiceLogs::class)->resolve($server, $data['key']);
30+
abort_if($log === null, 404);
31+
32+
$lines = (int) ($data['lines'] ?? 100);
33+
$search = $data['search'] ?? null;
34+
$hasSearch = $search !== null && $search !== '';
35+
36+
if ($log->source === ServiceLog::SOURCE_JOURNAL) {
37+
$content = $server->ssh()->exec(view('ssh.os.journal-read', [
38+
'unit' => $log->target,
39+
'lines' => $lines,
40+
'search' => $hasSearch ? $search : null,
41+
]));
42+
} elseif ($hasSearch) {
43+
$content = $server->ssh()->exec(view('ssh.os.grep', [
44+
'path' => $log->target,
45+
'term' => $search,
46+
'lines' => $lines,
47+
]));
48+
} else {
49+
$content = $server->os()->tail($log->target, $lines);
50+
}
51+
52+
abort_if(
53+
$log->source === ServiceLog::SOURCE_FILE && trim($content) === OS::FILE_NOT_FOUND,
54+
404,
55+
'The log file does not exist on the server.'
56+
);
57+
58+
return [
59+
'content' => $content,
60+
'display_target' => $log->displayTarget(),
61+
'source' => $log->source,
62+
];
63+
}
64+
}

app/DTOs/ServiceLog.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\DTOs;
4+
5+
final class ServiceLog
6+
{
7+
public const SOURCE_FILE = 'file';
8+
9+
public const SOURCE_JOURNAL = 'journal';
10+
11+
public function __construct(
12+
public string $key,
13+
public string $serviceLabel,
14+
public string $label,
15+
public string $source,
16+
public string $target,
17+
) {}
18+
19+
public function displayTarget(): string
20+
{
21+
if ($this->source === self::SOURCE_JOURNAL) {
22+
return 'journal: '.$this->target;
23+
}
24+
25+
return $this->target;
26+
}
27+
}

app/Http/Controllers/ServerLogController.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Actions\Server\ClearServiceLog;
6+
use App\Actions\Server\DownloadServiceLog;
7+
use App\Actions\Server\GetServiceLogs;
8+
use App\Actions\Server\ReadServiceLog;
59
use App\Actions\ServerLog\CreateLog;
610
use App\Actions\ServerLog\UpdateLog;
11+
use App\DTOs\ServiceLog;
712
use App\Helpers\QueryBuilder;
813
use App\Http\Resources\ServerLogResource;
914
use App\Models\Server;
1015
use App\Models\ServerLog;
1116
use App\Models\Site;
17+
use Illuminate\Http\JsonResponse;
1218
use Illuminate\Http\RedirectResponse;
1319
use Illuminate\Http\Request;
1420
use Illuminate\Http\Resources\Json\ResourceCollection;
@@ -50,7 +56,7 @@ public function remote(Server $server): Response
5056
$this->authorize('viewAny', [ServerLog::class, $server]);
5157

5258
return Inertia::render('server-logs/index', [
53-
'title' => 'Remote logs',
59+
'title' => 'Custom logs',
5460
'logs' => ServerLogResource::collection($server->logs()->where('is_remote', 1)->latest()->simplePaginate(config('web.pagination_size'))),
5561
'remote' => true,
5662
]);
@@ -69,6 +75,56 @@ public function json(Request $request, Server $server, ?Site $site = null): Reso
6975
return ServerLogResource::collection($logs);
7076
}
7177

78+
#[Get('/services', name: 'logs.services')]
79+
public function services(Server $server): Response
80+
{
81+
$this->authorize('viewAny', [ServerLog::class, $server]);
82+
83+
$catalogue = array_map(
84+
fn (ServiceLog $log): array => [
85+
'key' => $log->key,
86+
'service_label' => $log->serviceLabel,
87+
'label' => $log->label,
88+
'display_target' => $log->displayTarget(),
89+
'source' => $log->source,
90+
],
91+
app(GetServiceLogs::class)->handle($server),
92+
);
93+
94+
return Inertia::render('server-logs/services', [
95+
'title' => 'Service logs',
96+
'catalogue' => $catalogue,
97+
]);
98+
}
99+
100+
#[Post('/services/read', name: 'logs.services.read')]
101+
public function readServiceLog(Request $request, Server $server): JsonResponse
102+
{
103+
$this->authorize('viewAny', [ServerLog::class, $server]);
104+
105+
return response()->json(
106+
app(ReadServiceLog::class)->run($server, $request->only('key', 'lines', 'search')),
107+
);
108+
}
109+
110+
#[Get('/services/download', name: 'logs.services.download')]
111+
public function downloadServiceLog(Request $request, Server $server): StreamedResponse
112+
{
113+
$this->authorize('viewAny', [ServerLog::class, $server]);
114+
115+
return app(DownloadServiceLog::class)->run($server, $request->only('key'));
116+
}
117+
118+
#[Post('/services/clear', name: 'logs.services.clear')]
119+
public function clearServiceLog(Request $request, Server $server): RedirectResponse
120+
{
121+
$this->authorize('deleteMany', [ServerLog::class, $server]);
122+
123+
app(ClearServiceLog::class)->run($server, $request->only('key'));
124+
125+
return back()->with('success', 'Log cleared successfully');
126+
}
127+
72128
#[Get('/{log}', name: 'logs.show')]
73129
public function show(Server $server, ServerLog $log): string
74130
{

app/Models/ServerLog.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\DTOs\SocketEventDTO;
66
use App\Events\SocketEvent;
77
use App\Http\Resources\ServerLogResource;
8+
use App\SSH\OS\OS;
89
use Database\Factories\ServerLogFactory;
910
use Exception;
1011
use Illuminate\Database\Eloquent\Builder;
@@ -169,7 +170,9 @@ public function write(string $buf): void
169170
public function getContent(?int $lines = null): ?string
170171
{
171172
if ($this->is_remote) {
172-
return $this->server->os()->tail($this->name, $lines ?? 150);
173+
$content = $this->server->os()->tail($this->name, $lines ?? 150);
174+
175+
return trim($content) === OS::FILE_NOT_FOUND ? "Log file doesn't exist or is empty!" : $content;
173176
}
174177

175178
if (Storage::disk($this->disk)->exists($this->name)) {

app/SSH/OS/OS.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
class OS
1212
{
13+
public const FILE_NOT_FOUND = 'VITO_NO_FILE';
14+
1315
private const SHELL_IDENTIFIER = '/^[A-Za-z_][A-Za-z0-9_]*$/';
1416

1517
public function __construct(protected Server $server) {}

0 commit comments

Comments
 (0)