feat: add system log viewer module with file selection and parsing capabilities
This commit is contained in:
parent
cae6bc929a
commit
6958f28b9c
90
app/Http/Controllers/Admin/System/LogController.php
Normal file
90
app/Http/Controllers/Admin/System/LogController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
97
resources/js/pages/admin/system/logs/index.tsx
Normal file
97
resources/js/pages/admin/system/logs/index.tsx
Normal 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' },
|
||||
],
|
||||
};
|
||||
104
resources/js/pages/admin/system/logs/partials/columns.tsx
Normal file
104
resources/js/pages/admin/system/logs/partials/columns.tsx
Normal 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" },
|
||||
},
|
||||
];
|
||||
@ -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
10
routes/system.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -14,3 +14,4 @@
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/master.php';
|
||||
require __DIR__.'/finance.php';
|
||||
require __DIR__.'/system.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user