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'],
|
'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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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',
|
'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) {
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
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\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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user