siakad-itm/resources/js/pages/admin/developer/logs/index.tsx

220 lines
7.4 KiB
TypeScript

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} &middot;{' '}
{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>
</>
);
}