feat: implement log management system with viewing and downloading capabilities

This commit is contained in:
Yoga Pangestu 2026-08-30 23:39:07 +07:00
parent 5994f38f01
commit acda85b4e5
9 changed files with 546 additions and 1 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin\Developer;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\Developer\LogViewerService;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class LogController extends Controller
{
public function __construct(
private readonly LogViewerService $service,
) {}
public function index(PaginatedRequest $request): Response
{
$files = $this->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));
}
}

View File

@ -35,6 +35,8 @@ public function rules(): array
'lecturer_id' => ['nullable', 'integer'], 'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())], 'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
'file' => ['nullable', 'string'],
'level' => ['nullable', 'string'],
]; ];
} }

View File

@ -0,0 +1,112 @@
<?php
namespace App\Services\Admin\Developer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
class LogViewerService
{
private const ENTRY_PATTERN = '/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\]\s+(\w+)\.(\w+):\s?(.*)$/s';
/**
* @return Collection<int, array{name: string, size: int, modified_at: string}>
*/
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<int, array{id: string, timestamp: string, environment: string, level: string, message: string, raw: string}>
*/
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);
}
}

View File

@ -50,6 +50,10 @@ public function run(): void
'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status', 'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status',
]; ];
$developer = [
'view-logs',
];
$permissionNames = [ $permissionNames = [
'view-dashboard', 'view-dashboard',
...$master, ...$master,
@ -58,6 +62,7 @@ public function run(): void
...$finances, ...$finances,
...$services, ...$services,
...$users, ...$users,
...$developer,
]; ];
foreach ($permissionNames as $name) { foreach ($permissionNames as $name) {

View File

@ -16,6 +16,7 @@ import {
MessageCircle, MessageCircle,
Receipt, Receipt,
School, School,
ScrollText,
User, User,
Users, Users,
} from 'lucide-react'; } 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 attendancesRoute } from '@/routes/admin/academic-classes/attendances';
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials'; import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules'; 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 feedbackRoute } from '@/routes/admin/feedback';
import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices'; import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices';
import { index as announcementsRoute } from '@/routes/admin/manage/announcements'; import { index as announcementsRoute } from '@/routes/admin/manage/announcements';
@ -60,9 +62,11 @@ const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
function buildNavMain({ function buildNavMain({
isMahasiswa, isMahasiswa,
isDosen, isDosen,
canViewLogs,
}: { }: {
isMahasiswa: boolean; isMahasiswa: boolean;
isDosen: boolean; isDosen: boolean;
canViewLogs: boolean;
}): (NavGroup | NavItem)[] { }): (NavGroup | NavItem)[] {
return [ 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<typeof Sidebar>) {
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
const isDosen = !isStaff && roleNames.includes('dosen'); 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 ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>

View File

@ -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<LogEntry>[] {
const { handleViewDetail } = params;
return [
{
accessorKey: 'timestamp',
header: () => <span>Waktu</span>,
meta: {
className: 'w-[180px]',
headerClassName: 'w-[180px]',
},
cell: ({ row }) => {
const timestamp = row.getValue('timestamp') as string;
const date = new Date(timestamp.replace(' ', 'T'));
return (
<span className="whitespace-nowrap">
{Number.isNaN(date.getTime())
? timestamp
: format(date, 'd MMM yyyy, HH:mm:ss')}
</span>
);
},
},
{
accessorKey: 'level',
header: () => <span className="block text-center">Level</span>,
meta: {
className: 'w-[110px] text-center',
headerClassName: 'w-[110px] text-center',
},
cell: ({ row }) => {
const level = row.getValue('level') as LogLevel;
return (
<div className="flex justify-center">
<Badge variant={levelVariant(level)}>{level}</Badge>
</div>
);
},
},
{
accessorKey: 'message',
header: () => <span>Pesan</span>,
cell: ({ row }) => (
<p className="line-clamp-2 max-w-2xl font-mono text-xs break-all">
{firstLine(row.original.message)}
</p>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleViewDetail(row.original),
},
]}
/>
),
},
];
}

View File

@ -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<LogEntry | null>(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 (
<>
<Head title="Logs" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Logs"
actions={
selectedFile && (
<Button variant="outline" asChild>
<a
href={download.url({
query: { file: selectedFile },
})}
>
<Download className="h-4 w-4" />
Unduh
</a>
</Button>
)
}
/>
<div className="flex flex-wrap items-center gap-3">
<Select
value={selectedFile ?? undefined}
onValueChange={handleFileChange}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Pilih file log" />
</SelectTrigger>
<SelectContent>
{files.map((file) => (
<SelectItem key={file.name} value={file.name}>
{file.name} &middot;{' '}
{formatFileSize(file.size)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{entries ? (
<DataTable
columns={columns}
data={entries.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="message"
searchPlaceholder="Cari pesan log..."
emptyText="Tidak ada entri log yang cocok."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
) : (
<p className="text-sm text-muted-foreground">
Belum ada file log yang tersedia.
</p>
)}
<Dialog
open={detail !== null}
onOpenChange={(open) => {
if (!open) {
setDetail(null);
}
}}
>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{detail?.level && (
<Badge variant="outline">
{detail.level}
</Badge>
)}
<span>Detail Log</span>
</DialogTitle>
<DialogDescription>
{detail &&
!Number.isNaN(
new Date(
detail.timestamp.replace(' ', 'T'),
).getTime(),
) &&
format(
new Date(
detail.timestamp.replace(' ', 'T'),
),
'd MMM yyyy, HH:mm:ss',
)}
</DialogDescription>
</DialogHeader>
<pre className="overflow-y-auto rounded-md bg-muted p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.raw}
</pre>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

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

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Admin\AcademicClasses\MaterialController; use App\Http\Controllers\Admin\AcademicClasses\MaterialController;
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController; use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
use App\Http\Controllers\Admin\AcademicClasses\SubmissionController; 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\FeedbackController;
use App\Http\Controllers\Admin\Finances\TuitionInvoiceController; use App\Http\Controllers\Admin\Finances\TuitionInvoiceController;
use App\Http\Controllers\Admin\Finances\TuitionPaymentController; 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}/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::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');
});
}); });