From acda85b4e56b5263795f56bf6d2edbc8f474feca Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 30 Aug 2026 23:39:07 +0700 Subject: [PATCH] feat: implement log management system with viewing and downloading capabilities --- .../Admin/Developer/LogController.php | 49 ++++ app/Http/Requests/PaginatedRequest.php | 2 + .../Admin/Developer/LogViewerService.php | 112 +++++++++ database/seeders/RolePermissionSeeder.php | 5 + resources/js/components/app-sidebar.tsx | 21 +- .../js/pages/admin/developer/logs/columns.tsx | 106 +++++++++ .../js/pages/admin/developer/logs/index.tsx | 219 ++++++++++++++++++ resources/js/types/log-entry.ts | 27 +++ routes/admin.php | 6 + 9 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Developer/LogController.php create mode 100644 app/Services/Admin/Developer/LogViewerService.php create mode 100644 resources/js/pages/admin/developer/logs/columns.tsx create mode 100644 resources/js/pages/admin/developer/logs/index.tsx create mode 100644 resources/js/types/log-entry.ts diff --git a/app/Http/Controllers/Admin/Developer/LogController.php b/app/Http/Controllers/Admin/Developer/LogController.php new file mode 100644 index 0000000..9067867 --- /dev/null +++ b/app/Http/Controllers/Admin/Developer/LogController.php @@ -0,0 +1,49 @@ +service->availableFiles(); + $file = $request->validated('file') ?: $files->first()['name'] ?? null; + + return Inertia::render('admin/developer/logs/index', [ + 'entries' => $file + ? $this->service->paginated( + $file, + ...$request->validatedWithDefaults(), + level: $request->validated('level'), + ) + : null, + 'files' => $files, + 'selectedFile' => $file, + 'filters' => $request->only(['file', 'level']), + ]); + } + + public function download(PaginatedRequest $request): BinaryFileResponse + { + $file = $request->validated('file'); + + abort_unless($file, 404); + + $files = $this->service->availableFiles(); + + abort_unless($files->pluck('name')->contains($file), 404); + + return response()->download(storage_path('logs/'.$file)); + } +} diff --git a/app/Http/Requests/PaginatedRequest.php b/app/Http/Requests/PaginatedRequest.php index fe0c2cf..5089a74 100644 --- a/app/Http/Requests/PaginatedRequest.php +++ b/app/Http/Requests/PaginatedRequest.php @@ -35,6 +35,8 @@ public function rules(): array 'lecturer_id' => ['nullable', 'integer'], 'type' => ['nullable', 'string'], 'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())], + 'file' => ['nullable', 'string'], + 'level' => ['nullable', 'string'], ]; } diff --git a/app/Services/Admin/Developer/LogViewerService.php b/app/Services/Admin/Developer/LogViewerService.php new file mode 100644 index 0000000..f14d661 --- /dev/null +++ b/app/Services/Admin/Developer/LogViewerService.php @@ -0,0 +1,112 @@ + + */ + public function availableFiles(): Collection + { + return collect(File::glob(storage_path('logs/laravel*.log'))) + ->map(fn (string $path) => [ + 'name' => basename($path), + 'size' => File::size($path), + 'modified_at' => date('Y-m-d H:i:s', File::lastModified($path)), + 'modified_timestamp' => File::lastModified($path), + ]) + ->sortByDesc('modified_timestamp') + ->values() + ->map(fn (array $file) => collect($file)->except('modified_timestamp')->all()); + } + + public function latestFileName(): ?string + { + return $this->availableFiles()->first()['name'] ?? null; + } + + public function paginated(string $fileName, int $perPage = 25, string $search = '', ?string $level = null): LengthAwarePaginator + { + $entries = $this->parse($fileName) + ->when($level, fn (Collection $q) => $q->where('level', strtoupper($level))) + ->when($search, fn (Collection $q) => $q->filter( + fn (array $entry) => str_contains(strtolower($entry['message']), strtolower($search)) + )) + ->values(); + + $page = request()->integer('page', 1); + $items = $entries->forPage($page, $perPage)->values(); + + return new Paginator( + $items, + $entries->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()], + ); + } + + /** + * @return Collection + */ + private function parse(string $fileName): Collection + { + $path = $this->resolvePath($fileName); + + if (! $path) { + return collect(); + } + + $lines = preg_split('/\R/', File::get($path)) ?: []; + + $entries = []; + $current = null; + + foreach ($lines as $line) { + if (preg_match(self::ENTRY_PATTERN, $line, $matches)) { + if ($current) { + $entries[] = $current; + } + + $current = [ + 'timestamp' => $matches[1], + 'environment' => $matches[2], + 'level' => strtoupper($matches[3]), + 'message' => $matches[4], + 'raw' => $line, + ]; + } elseif ($current !== null && trim($line) !== '') { + $current['message'] .= "\n".$line; + $current['raw'] .= "\n".$line; + } + } + + if ($current) { + $entries[] = $current; + } + + return collect($entries) + ->reverse() + ->values() + ->map(fn (array $entry, int $index) => [...$entry, 'id' => (string) $index]); + } + + private function resolvePath(string $fileName): ?string + { + $allowed = $this->availableFiles()->pluck('name'); + + if (! $allowed->contains($fileName)) { + return null; + } + + return storage_path('logs/'.$fileName); + } +} diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 79f3ad9..991d897 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -50,6 +50,10 @@ public function run(): void 'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status', ]; + $developer = [ + 'view-logs', + ]; + $permissionNames = [ 'view-dashboard', ...$master, @@ -58,6 +62,7 @@ public function run(): void ...$finances, ...$services, ...$users, + ...$developer, ]; foreach ($permissionNames as $name) { diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 4f880e7..34547b6 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -16,6 +16,7 @@ import { MessageCircle, Receipt, School, + ScrollText, User, Users, } from 'lucide-react'; @@ -37,6 +38,7 @@ import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assig import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances'; import { index as materialsRoute } from '@/routes/admin/academic-classes/materials'; import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules'; +import { index as logsRoute } from '@/routes/admin/developer/logs'; import { index as feedbackRoute } from '@/routes/admin/feedback'; import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices'; import { index as announcementsRoute } from '@/routes/admin/manage/announcements'; @@ -60,9 +62,11 @@ const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi']; function buildNavMain({ isMahasiswa, isDosen, + canViewLogs, }: { isMahasiswa: boolean; isDosen: boolean; + canViewLogs: boolean; }): (NavGroup | NavItem)[] { return [ { @@ -186,6 +190,20 @@ function buildNavMain({ }, ], }, + ...(canViewLogs + ? [ + { + label: 'Pengembang', + items: [ + { + name: 'Logs', + url: logsRoute.url(), + icon: ScrollText, + }, + ], + }, + ] + : []), ]; } @@ -208,7 +226,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const isDosen = !isStaff && roleNames.includes('dosen'); - const navMain = buildNavMain({ isMahasiswa, isDosen }); + const canViewLogs = (auth?.permissions ?? []).includes('view-logs'); + const navMain = buildNavMain({ isMahasiswa, isDosen, canViewLogs }); return ( diff --git a/resources/js/pages/admin/developer/logs/columns.tsx b/resources/js/pages/admin/developer/logs/columns.tsx new file mode 100644 index 0000000..538fa6a --- /dev/null +++ b/resources/js/pages/admin/developer/logs/columns.tsx @@ -0,0 +1,106 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Eye } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; +import type { LogEntry, LogLevel } from '@/types/log-entry'; + +function levelVariant( + level: LogLevel, +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (['EMERGENCY', 'ALERT', 'CRITICAL', 'ERROR'].includes(level)) { + return 'destructive'; + } + + if (['WARNING', 'NOTICE'].includes(level)) { + return 'secondary'; + } + + if (level === 'INFO') { + return 'default'; + } + + return 'outline'; +} + +function firstLine(message: string): string { + return message.split('\n')[0]; +} + +type CreateColumnsParams = { + handleViewDetail: (entry: LogEntry) => void; +}; + +export function createLogColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleViewDetail } = params; + + return [ + { + accessorKey: 'timestamp', + header: () => Waktu, + meta: { + className: 'w-[180px]', + headerClassName: 'w-[180px]', + }, + cell: ({ row }) => { + const timestamp = row.getValue('timestamp') as string; + const date = new Date(timestamp.replace(' ', 'T')); + + return ( + + {Number.isNaN(date.getTime()) + ? timestamp + : format(date, 'd MMM yyyy, HH:mm:ss')} + + ); + }, + }, + { + accessorKey: 'level', + header: () => Level, + meta: { + className: 'w-[110px] text-center', + headerClassName: 'w-[110px] text-center', + }, + cell: ({ row }) => { + const level = row.getValue('level') as LogLevel; + + return ( +
+ {level} +
+ ); + }, + }, + { + accessorKey: 'message', + header: () => Pesan, + cell: ({ row }) => ( +

+ {firstLine(row.original.message)} +

+ ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row }) => ( + , + onClick: () => handleViewDetail(row.original), + }, + ]} + /> + ), + }, + ]; +} diff --git a/resources/js/pages/admin/developer/logs/index.tsx b/resources/js/pages/admin/developer/logs/index.tsx new file mode 100644 index 0000000..45f5821 --- /dev/null +++ b/resources/js/pages/admin/developer/logs/index.tsx @@ -0,0 +1,219 @@ +import { Head, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { Download } from 'lucide-react'; +import { useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import type { FilterField } from '@/components/filter-dialog'; +import { FilterDialog } from '@/components/filter-dialog'; +import { PageHeader } from '@/components/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useServerTable } from '@/hooks/use-server-table'; +import { index as logsIndex, download } from '@/routes/admin/developer/logs'; +import type { LogEntry, LogFile } from '@/types/log-entry'; +import { LogLevels } from '@/types/log-entry'; +import { createLogColumns } from './columns'; + +type Props = { + entries: { + data: LogEntry[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + } | null; + files: LogFile[]; + selectedFile: string | null; + filters: { + file?: string; + level?: string; + }; +}; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function LogIndex({ + entries, + files, + selectedFile, + filters, +}: Props) { + const [detail, setDetail] = useState(null); + + const filterFields: FilterField[] = [ + { + key: 'level', + label: 'Level', + options: LogLevels.map((level) => ({ + value: level, + label: level, + })), + }, + ]; + + const pagination: PaginationState = { + current_page: entries?.current_page ?? 1, + last_page: entries?.last_page ?? 1, + per_page: entries?.per_page ?? 25, + total: entries?.total ?? 0, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + applyFilters, + } = useServerTable({ + route: () => logsIndex.url(), + pagination, + filters, + }); + + function handleFileChange(file: string) { + router.get( + logsIndex.url(), + { file, level: filters.level }, + { preserveState: true, replace: true }, + ); + } + + const columns = createLogColumns({ + handleViewDetail: (entry) => setDetail(entry), + }); + + return ( + <> + + +
+ + + + Unduh + + + ) + } + /> + +
+ +
+ + {entries ? ( + + } + /> + ) : ( +

+ Belum ada file log yang tersedia. +

+ )} + + { + if (!open) { + setDetail(null); + } + }} + > + + + + {detail?.level && ( + + {detail.level} + + )} + Detail Log + + + {detail && + !Number.isNaN( + new Date( + detail.timestamp.replace(' ', 'T'), + ).getTime(), + ) && + format( + new Date( + detail.timestamp.replace(' ', 'T'), + ), + 'd MMM yyyy, HH:mm:ss', + )} + + +
+                            {detail?.raw}
+                        
+
+
+
+ + ); +} diff --git a/resources/js/types/log-entry.ts b/resources/js/types/log-entry.ts new file mode 100644 index 0000000..93db439 --- /dev/null +++ b/resources/js/types/log-entry.ts @@ -0,0 +1,27 @@ +export const LogLevels = [ + 'EMERGENCY', + 'ALERT', + 'CRITICAL', + 'ERROR', + 'WARNING', + 'NOTICE', + 'INFO', + 'DEBUG', +] as const; + +export type LogLevel = (typeof LogLevels)[number]; + +export type LogEntry = { + id: string; + timestamp: string; + environment: string; + level: LogLevel; + message: string; + raw: string; +}; + +export type LogFile = { + name: string; + size: number; + modified_at: string; +}; diff --git a/routes/admin.php b/routes/admin.php index 76107e0..28558c2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Admin\AcademicClasses\MaterialController; use App\Http\Controllers\Admin\AcademicClasses\ScheduleController; use App\Http\Controllers\Admin\AcademicClasses\SubmissionController; +use App\Http\Controllers\Admin\Developer\LogController; use App\Http\Controllers\Admin\FeedbackController; use App\Http\Controllers\Admin\Finances\TuitionInvoiceController; use App\Http\Controllers\Admin\Finances\TuitionPaymentController; @@ -236,4 +237,9 @@ Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password')->middleware('permission:reset-administrators-password'); Route::patch('administrators/{user}/user-status', [AdministratorController::class, 'updateUserStatus'])->name('administrators.update_user_status')->middleware('permission:update-administrators-status'); }); + + Route::prefix('admin/developer')->name('admin.developer.')->middleware('permission:view-logs')->group(function () { + Route::get('logs', [LogController::class, 'index'])->name('logs.index'); + Route::get('logs/download', [LogController::class, 'download'])->name('logs.download'); + }); });