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 { 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 AppLogo from '@/components/app-logo';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/nav-main';
|
||||||
import {
|
import {
|
||||||
@ -18,6 +18,7 @@ import product from '@/routes/product';
|
|||||||
import expense from '@/routes/expense';
|
import expense from '@/routes/expense';
|
||||||
import payroll from '@/routes/payroll';
|
import payroll from '@/routes/payroll';
|
||||||
import user from '@/routes/user';
|
import user from '@/routes/user';
|
||||||
|
import system from '@/routes/system';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
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() {
|
export function AppSidebar() {
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar collapsible="icon" variant="inset">
|
||||||
@ -77,6 +86,7 @@ export function AppSidebar() {
|
|||||||
<NavMain items={mainNavItems} />
|
<NavMain items={mainNavItems} />
|
||||||
<NavMain items={masterNavItems} label='Master' />
|
<NavMain items={masterNavItems} label='Master' />
|
||||||
<NavMain items={financeNavItems} label='Keuangan' />
|
<NavMain items={financeNavItems} label='Keuangan' />
|
||||||
|
<NavMain items={systemNavItems} label='Sistem' />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</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__.'/settings.php';
|
||||||
require __DIR__.'/master.php';
|
require __DIR__.'/master.php';
|
||||||
require __DIR__.'/finance.php';
|
require __DIR__.'/finance.php';
|
||||||
|
require __DIR__.'/system.php';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user