feat: add system log viewer module with file selection and parsing capabilities

This commit is contained in:
Yoga Pangestu 2026-04-18 16:37:28 +07:00
parent cae6bc929a
commit 6958f28b9c
7 changed files with 350 additions and 1 deletions

View File

@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Inertia\Inertia;
use Inertia\Response;
class LogController extends Controller
{
/**
* Display a listing of log entries.
*/
public function index(Request $request): Response
{
$logFiles = $this->getLogFiles();
// Default to the latest log file if not specified or doesn't exist
$selectedFile = $request->input('file');
if (! $selectedFile || ! in_array($selectedFile, $logFiles)) {
$selectedFile = $logFiles[0] ?? null;
}
$logs = $selectedFile ? $this->parseLogFile($selectedFile) : [];
return Inertia::render('admin/system/logs/index', [
'logFiles' => $logFiles,
'selectedFile' => $selectedFile,
'logs' => $logs,
]);
}
/**
* Get all .log files from storage/logs.
*/
private function getLogFiles(): array
{
$path = storage_path('logs');
if (! File::exists($path)) {
return [];
}
$files = File::files($path);
return collect($files)
->map(fn ($file) => $file->getFilename())
->filter(fn ($filename) => str_ends_with($filename, '.log'))
->sortDesc() // Latest dates first
->values()
->toArray();
}
/**
* Parse a log file into an array of entries.
*/
private function parseLogFile($filename): array
{
$path = storage_path("logs/{$filename}");
if (! File::exists($path)) {
return [];
}
// Limit file reading to prevent memory issues with huge logs
// For simplicity, we read the whole file here, but in production consider chunking.
$content = File::get($path);
// Pattern: [timestamp] environment.LEVEL: Message
// Handles multi-line messages (like stack traces) using lookahead
$pattern = '/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+)\.(\w+): ([\s\S]*?)(?=\n^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]|$)/m';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
$logs = [];
foreach ($matches as $index => $match) {
$logs[] = [
'id' => $index,
'timestamp' => $match[1],
'env' => $match[2],
'level' => strtoupper($match[3]),
'message' => trim($match[4]),
];
}
// Return latest logs first
return array_reverse($logs);
}
}

View File

@ -1,5 +1,5 @@
import { Link } from '@inertiajs/react';
import { Boxes, Currency, DollarSign, LayoutGrid, List, User, Wallet, WalletCardsIcon } from 'lucide-react';
import { Boxes, Currency, DollarSign, LayoutGrid, List, ScrollText, User, Wallet, WalletCardsIcon } from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavMain } from '@/components/nav-main';
import {
@ -18,6 +18,7 @@ import product from '@/routes/product';
import expense from '@/routes/expense';
import payroll from '@/routes/payroll';
import user from '@/routes/user';
import system from '@/routes/system';
const mainNavItems: NavItem[] = [
{
@ -58,6 +59,14 @@ const financeNavItems: NavItem[] = [
},
];
const systemNavItems: NavItem[] = [
{
title: 'Logs',
href: system.logs.index().url,
icon: ScrollText,
},
];
export function AppSidebar() {
return (
<Sidebar collapsible="icon" variant="inset">
@ -77,6 +86,7 @@ export function AppSidebar() {
<NavMain items={mainNavItems} />
<NavMain items={masterNavItems} label='Master' />
<NavMain items={financeNavItems} label='Keuangan' />
<NavMain items={systemNavItems} label='Sistem' />
</SidebarContent>
</Sidebar>
);

View File

@ -0,0 +1,97 @@
import { Head, router } from '@inertiajs/react';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/data-table';
import { useState } from 'react';
import { getColumns, SystemLog } from './partials/columns';
import system from '@/routes/system';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { LogDetailModal } from './partials/log-detail-modal';
interface Props {
logFiles: string[];
selectedFile: string;
logs: SystemLog[];
}
export default function SystemLogIndex({ logFiles, selectedFile, logs }: Props) {
const [viewingLog, setViewingLog] = useState<SystemLog | null>(null);
const columns = getColumns({
onView: (log) => setViewingLog(log)
});
const onFileChange = (file: string) => {
router.get(system.logs.index().url, { file }, {
preserveState: true,
preserveScroll: true,
only: ['logs', 'selectedFile']
});
};
return (
<div className="flex flex-col gap-6 p-6">
<Head title="Logs" />
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Logs</h1>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">File Log:</span>
<Select value={selectedFile} onValueChange={onFileChange}>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="Pilih file" />
</SelectTrigger>
<SelectContent align="end">
{logFiles.map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
<CardContent className="p-0">
<DataTable
columns={columns}
data={logs}
filters={[
{
columnId: 'level',
title: 'Level',
options: [
{ label: 'ERROR', value: 'ERROR' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'INFO', value: 'INFO' },
{ label: 'DEBUG', value: 'DEBUG' },
]
}
]}
/>
</CardContent>
</Card>
<LogDetailModal
log={viewingLog}
onClose={() => setViewingLog(null)}
/>
</div>
);
}
SystemLogIndex.layout = {
breadcrumbs: [
{ title: 'Sistem' },
],
};

View File

@ -0,0 +1,104 @@
import { ColumnDef } from '@tanstack/react-table';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface SystemLog {
id: number;
timestamp: string;
env: string;
level: string;
message: string;
}
interface ColumnProps {
onView: (log: SystemLog) => void;
}
const getLevelColor = (level: string) => {
switch (level.toUpperCase()) {
case 'EMERGENCY':
case 'ALERT':
case 'CRITICAL':
case 'ERROR':
return 'destructive';
case 'WARNING':
return 'outline'; // Fallback if no warning variant
case 'NOTICE':
case 'INFO':
return 'secondary';
case 'DEBUG':
return 'outline';
default:
return 'default';
}
}
export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
{
accessorKey: "timestamp",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Waktu" />
),
cell: ({ row }) => <span className="font-mono text-xs text-nowrap">{row.original.timestamp}</span>,
meta: { title: "Waktu" },
},
{
accessorKey: "level",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Level" />
),
cell: ({ row }) => {
const level = row.original.level;
return (
<Badge variant={getLevelColor(level) as any}>
{level}
</Badge>
);
},
meta: { title: "Level" },
},
{
accessorKey: "message",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Pesan" />
),
cell: ({ row }) => {
const message = row.original.message;
return (
<div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}>
{message}
</div>
);
},
meta: { title: "Pesan" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const log = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onView(log)}
>
<Eye className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Detail</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -0,0 +1,37 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { SystemLog } from "./columns"
interface Props {
log: SystemLog | null;
onClose: () => void;
}
export function LogDetailModal({ log, onClose }: Props) {
if (!log) return null;
return (
<Dialog open={!!log} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-center gap-3">
<DialogTitle>Detail Log</DialogTitle>
<Badge variant="outline">{log.level}</Badge>
<span className="text-sm text-muted-foreground">{log.timestamp}</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-4 rounded-lg bg-muted/50 p-4 border">
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
{log.message}
</pre>
</div>
</DialogContent>
</Dialog>
);
}

10
routes/system.php Normal file
View File

@ -0,0 +1,10 @@
<?php
use App\Http\Controllers\Admin\System\LogController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function () {
Route::prefix('admin/system')->group(function () {
Route::get('logs', [LogController::class, 'index'])->name('system.logs.index');
});
});

View File

@ -14,3 +14,4 @@
require __DIR__.'/settings.php';
require __DIR__.'/master.php';
require __DIR__.'/finance.php';
require __DIR__.'/system.php';