feat: implement log management system with viewing and downloading capabilities
This commit is contained in:
parent
5994f38f01
commit
acda85b4e5
49
app/Http/Controllers/Admin/Developer/LogController.php
Normal file
49
app/Http/Controllers/Admin/Developer/LogController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
112
app/Services/Admin/Developer/LogViewerService.php
Normal file
112
app/Services/Admin/Developer/LogViewerService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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<typeof Sidebar>) {
|
||||
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 (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
|
||||
106
resources/js/pages/admin/developer/logs/columns.tsx
Normal file
106
resources/js/pages/admin/developer/logs/columns.tsx
Normal 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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
219
resources/js/pages/admin/developer/logs/index.tsx
Normal file
219
resources/js/pages/admin/developer/logs/index.tsx
Normal 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} ·{' '}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
resources/js/types/log-entry.ts
Normal file
27
resources/js/types/log-entry.ts
Normal 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;
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user