From 857319025f932e6c41af20f55229118d53d1c92d Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Fri, 30 May 2025 13:52:39 +0200 Subject: [PATCH] #591 - server-logs --- .../CreateLog.php} | 7 +- app/Actions/ServerLog/UpdateLog.php | 34 ++++ app/Exceptions/Handler.php | 8 + app/Http/Controllers/ServerLogController.php | 75 +++++++++ resources/js/components/log-output.tsx | 2 +- resources/js/layouts/server/layout.tsx | 26 ++- .../pages/server-logs/components/columns.tsx | 157 ++++++++++++++---- .../js/pages/server-logs/components/form.tsx | 79 +++++++++ resources/js/pages/server-logs/index.tsx | 52 ++++++ tests/Feature/LogsTest.php | 30 ++-- 10 files changed, 416 insertions(+), 54 deletions(-) rename app/Actions/{Server/CreateServerLog.php => ServerLog/CreateLog.php} (80%) create mode 100755 app/Actions/ServerLog/UpdateLog.php create mode 100644 resources/js/pages/server-logs/components/form.tsx create mode 100644 resources/js/pages/server-logs/index.tsx diff --git a/app/Actions/Server/CreateServerLog.php b/app/Actions/ServerLog/CreateLog.php similarity index 80% rename from app/Actions/Server/CreateServerLog.php rename to app/Actions/ServerLog/CreateLog.php index 353e0292..ea2e300e 100755 --- a/app/Actions/Server/CreateServerLog.php +++ b/app/Actions/ServerLog/CreateLog.php @@ -1,11 +1,12 @@ $input @@ -14,6 +15,8 @@ class CreateServerLog */ public function create(Server $server, array $input): void { + Validator::make($input, self::rules())->validate(); + $server->logs()->create([ 'is_remote' => true, 'name' => $input['path'], diff --git a/app/Actions/ServerLog/UpdateLog.php b/app/Actions/ServerLog/UpdateLog.php new file mode 100755 index 00000000..22b25eda --- /dev/null +++ b/app/Actions/ServerLog/UpdateLog.php @@ -0,0 +1,34 @@ + $input + * + * @throws ValidationException + */ + public function update(ServerLog $serverLog, array $input): void + { + Validator::make($input, self::rules())->validate(); + + $serverLog->update([ + 'name' => $input['path'], + ]); + } + + /** + * @return array + */ + public static function rules(): array + { + return [ + 'path' => 'required', + ]; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index bc70055f..d40e6d01 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -54,6 +54,14 @@ public function render($request, Throwable $e): Response abort(404, class_basename($e->getModel()).' not found.'); } + if ($e instanceof SSHError) { + if ($request->header('X-Inertia')) { + return back()->with('error', $e->getLog()?->getContent(30) ?? $e->getMessage()); + } + + return response()->json(['error' => $e->getLog()?->getContent(30) ?? $e->getMessage()], 500); + } + return parent::render($request, $e); } } diff --git a/app/Http/Controllers/ServerLogController.php b/app/Http/Controllers/ServerLogController.php index 95835742..a58ffba8 100644 --- a/app/Http/Controllers/ServerLogController.php +++ b/app/Http/Controllers/ServerLogController.php @@ -2,19 +2,53 @@ namespace App\Http\Controllers; +use App\Actions\ServerLog\CreateLog; +use App\Actions\ServerLog\UpdateLog; use App\Http\Resources\ServerLogResource; use App\Models\Server; use App\Models\ServerLog; use App\Models\Site; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; +use Inertia\Inertia; +use Inertia\Response; +use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; +use Spatie\RouteAttributes\Attributes\Patch; +use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; #[Prefix('servers/{server}/logs')] #[Middleware(['auth', 'has-project'])] class ServerLogController extends Controller { + #[Get('/', name: 'logs')] + public function index(Server $server): Response + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + return Inertia::render('server-logs/index', [ + 'title' => 'Server logs', + 'logs' => ServerLogResource::collection($server->logs()->where('is_remote', 0)->latest()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Get('/remote', name: 'logs.remote')] + public function remote(Server $server): Response + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + return Inertia::render('server-logs/index', [ + 'title' => 'Remote logs', + 'logs' => ServerLogResource::collection($server->logs()->where('is_remote', 1)->latest()->simplePaginate(config('web.pagination_size'))), + 'remote' => true, + ]); + } + #[Get('/json/{site?}', name: 'logs.json')] public function json(Server $server, ?Site $site = null): ResourceCollection { @@ -35,4 +69,45 @@ public function show(Server $server, ServerLog $log): string return $log->getContent(); } + + /** + * @throws Throwable + */ + #[Get('/{log}/download', name: 'logs.download')] + public function download(Server $server, ServerLog $log): StreamedResponse + { + $this->authorize('view', $log); + + return $log->download(); + } + + #[Post('/', name: 'logs.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [ServerLog::class, $server]); + + app(CreateLog::class)->create($server, $request->input()); + + return back()->with('success', 'Log created successfully'); + } + + #[Patch('{log}', name: 'logs.update')] + public function update(Request $request, Server $server, ServerLog $log): RedirectResponse + { + $this->authorize('update', $log); + + app(UpdateLog::class)->update($log, $request->input()); + + return back()->with('success', 'Log updated successfully'); + } + + #[Delete('{log}', name: 'logs.destroy')] + public function destroy(Server $server, ServerLog $log): RedirectResponse + { + $this->authorize('delete', $log); + + $log->delete(); + + return back()->with('success', 'Log deleted successfully'); + } } diff --git a/resources/js/components/log-output.tsx b/resources/js/components/log-output.tsx index 8cec559f..84d782a0 100644 --- a/resources/js/components/log-output.tsx +++ b/resources/js/components/log-output.tsx @@ -26,7 +26,7 @@ export default function LogOutput({ children }: { children: ReactNode }) { className="bg-accent/50 text-accent-foreground relative h-[500px] w-full overflow-auto p-4 font-mono text-sm break-all whitespace-pre-wrap" >
{children}
-
+
- - - - View Log - This is all content of the log - - {query.isLoading ? 'Loading...' : query.data} - + + + e.preventDefault()}>View + + + + View Log + This is all content of the log + + + <> + {query.isLoading && 'Loading...'} + {query.isError &&
Error: {query.error.message}
} + {query.data && !query.isError && query.data} + +
+ + - -
-
-
+ + + + ); -}; +} + +function Download({ serverLog, children }: { serverLog: ServerLog; children: ReactNode }) { + return ( + + {children} + + ); +} + +function Delete({ serverLog }: { serverLog: ServerLog }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('logs.destroy', { server: serverLog.server_id, log: serverLog.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete {serverLog.name} + Delete log + +
+

+ Are you sure you want to delete {serverLog.name}? +

+
+ + + + + + +
+
+ ); +} export const columns: ColumnDef[] = [ { @@ -63,6 +141,27 @@ export const columns: ColumnDef[] = [ id: 'actions', enableColumnFilter: false, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => { + return ( +
+ + + + + + + + Download + + + + + +
+ ); + }, }, ]; diff --git a/resources/js/pages/server-logs/components/form.tsx b/resources/js/pages/server-logs/components/form.tsx new file mode 100644 index 00000000..d6dc4f95 --- /dev/null +++ b/resources/js/pages/server-logs/components/form.tsx @@ -0,0 +1,79 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { FormEvent, ReactNode, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm, usePage } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { ServerLog } from '@/types/server-log'; +import { Server } from '@/types/server'; + +export default function LogForm({ serverLog, children }: { serverLog?: ServerLog; children: ReactNode }) { + const [open, setOpen] = useState(false); + const page = usePage<{ server: Server }>(); + const form = useForm<{ + path: string; + }>({ + path: serverLog?.name || '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (serverLog) { + form.put(route('logs.update', { server: page.props.server.id, serverLog: serverLog.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + return; + } + + form.post(route('logs.store', { server: page.props.server.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + }; + return ( + + {children} + + + {serverLog ? 'Edit' : 'Create'} remote log + {serverLog ? 'Edit' : 'Create new'} remote log + +
+ + + + form.setData('path', e.target.value)} /> + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/server-logs/index.tsx b/resources/js/pages/server-logs/index.tsx new file mode 100644 index 00000000..6984def2 --- /dev/null +++ b/resources/js/pages/server-logs/index.tsx @@ -0,0 +1,52 @@ +import { Head, usePage } from '@inertiajs/react'; +import { PaginatedData } from '@/types'; +import { ServerLog } from '@/types/server-log'; +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 { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/server-logs/components/columns'; +import { Button } from '@/components/ui/button'; +import { BookOpenIcon, PlusIcon } from 'lucide-react'; +import LogForm from '@/pages/server-logs/components/form'; + +export default function ServerLogs() { + const page = usePage<{ + title: string; + server: Server; + logs: PaginatedData; + remote: boolean; + }>(); + + return ( + + + + + + +
+ + + + {page.props.remote && ( + + + + )} +
+
+ + +
+
+ ); +} diff --git a/tests/Feature/LogsTest.php b/tests/Feature/LogsTest.php index 487d5c24..5fdf1f3d 100644 --- a/tests/Feature/LogsTest.php +++ b/tests/Feature/LogsTest.php @@ -3,31 +3,28 @@ namespace Tests\Feature; use App\Models\ServerLog; -use App\Web\Pages\Servers\Logs\Index; -use App\Web\Pages\Servers\Logs\RemoteLogs; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class LogsTest extends TestCase { use RefreshDatabase; - public function test_see_logs() + public function test_see_logs(): void { $this->actingAs($this->user); - /** @var ServerLog $log */ - $log = ServerLog::factory()->create([ + ServerLog::factory()->create([ 'server_id' => $this->server->id, ]); - $this->get(Index::getUrl(['server' => $this->server])) + $this->get(route('logs', $this->server)) ->assertSuccessful() - ->assertSee($log->name); + ->assertInertia(fn (AssertableInertia $page) => $page->component('server-logs/index')); } - public function test_see_logs_remote() + public function test_see_logs_remote(): void { $this->actingAs($this->user); @@ -38,20 +35,19 @@ public function test_see_logs_remote() 'name' => 'see-remote-log', ]); - $this->get(RemoteLogs::getUrl(['server' => $this->server])) + $this->get(route('logs.remote', $this->server)) ->assertSuccessful() - ->assertSee('see-remote-log'); + ->assertInertia(fn (AssertableInertia $page) => $page->component('server-logs/index')); } - public function test_create_remote_log() + public function test_create_remote_log(): void { $this->actingAs($this->user); - Livewire::test(RemoteLogs::class, ['server' => $this->server]) - ->callAction('create', [ - 'path' => 'test-path', - ]) - ->assertSuccessful(); + $this->post(route('logs.store', $this->server), [ + 'path' => 'test-path', + ]) + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('server_logs', [ 'is_remote' => true,