Files
qlockify-frontend-deployment/src/pages/Logs.tsx

354 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { History, ShieldCheck, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import {
getWorkspaceLogDetail,
listWorkspaceLogs,
type WorkspaceLogDetail,
type WorkspaceLogItem,
} from "../api/logs";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
import { LogDetailsPanel } from "../components/logs/LogDetailsPanel";
import { LogsFeed } from "../components/logs/LogsFeed";
import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { readStringParam, updateQueryParams } from "../lib/queryParams";
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
const DEFAULT_FILTERS: LogsFilterDraft = {
search: "",
section: "",
event: "",
actor: "",
from: "",
to: "",
ordering: "-timestamp",
};
const DEFAULT_QUERY_FILTERS: Record<string, string> = {
search: DEFAULT_FILTERS.search,
section: DEFAULT_FILTERS.section,
event: DEFAULT_FILTERS.event,
actor: DEFAULT_FILTERS.actor,
from: DEFAULT_FILTERS.from,
to: DEFAULT_FILTERS.to,
ordering: DEFAULT_FILTERS.ordering,
};
const PAGE_SIZE = 20;
export default function Logs() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [searchParams, setSearchParams] = useSearchParams();
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [selectedLog, setSelectedLog] = useState<WorkspaceLogDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const workspaceRole = activeWorkspace?.my_role;
const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW);
const isWorkspaceRoleResolved = Boolean(workspaceRole);
const filters = useMemo<LogsFilterDraft>(
() => ({
search: readStringParam(searchParams, "search", DEFAULT_FILTERS.search),
section: readStringParam(searchParams, "section", DEFAULT_FILTERS.section) as LogsFilterDraft["section"],
event: readStringParam(searchParams, "event", DEFAULT_FILTERS.event) as LogsFilterDraft["event"],
actor: readStringParam(searchParams, "actor", DEFAULT_FILTERS.actor),
from: readStringParam(searchParams, "from", DEFAULT_FILTERS.from),
to: readStringParam(searchParams, "to", DEFAULT_FILTERS.to),
ordering: readStringParam(searchParams, "ordering", DEFAULT_FILTERS.ordering) as LogsFilterDraft["ordering"],
}),
[searchParams],
);
useEffect(() => {
setLogs([]);
setTotalLogs(0);
setSelectedLogId(null);
setSelectedLog(null);
}, [activeWorkspace?.id]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved || !canViewLogs) {
setMemberships([]);
setIsLoadingUsers(false);
return;
}
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const response = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: 200,
offset: 0,
});
setMemberships(response.results || []);
} catch {
setMemberships([]);
toast.error(t.logs?.loadFiltersError || "Failed to load log filters.");
} finally {
setIsLoadingUsers(false);
}
};
void loadUsers();
}, [activeWorkspace?.id, canViewLogs, isWorkspaceRoleResolved, t.logs?.loadFiltersError]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved || !canViewLogs) {
setLogs([]);
setTotalLogs(0);
setIsLoading(false);
return;
}
const loadLogs = async () => {
setIsLoading(true);
try {
const response = await listWorkspaceLogs(
{
workspace: activeWorkspace.id,
search: filters.search || undefined,
section: filters.section || undefined,
actor: filters.actor || undefined,
event: filters.event || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
ordering: filters.ordering,
},
{ limit: PAGE_SIZE, offset: 0 },
);
setLogs(response.results || []);
setTotalLogs(response.count || 0);
if (selectedLogId && !(response.results || []).some((item) => item.id === selectedLogId)) {
setSelectedLogId(null);
setSelectedLog(null);
}
} catch {
setLogs([]);
setTotalLogs(0);
toast.error(t.logs?.loadError || "Failed to load logs.");
} finally {
setIsLoading(false);
}
};
void loadLogs();
}, [
activeWorkspace?.id,
canViewLogs,
filters.actor,
filters.event,
filters.from,
filters.ordering,
filters.search,
filters.section,
filters.to,
isWorkspaceRoleResolved,
t.logs?.loadError,
]);
const handleLoadMore = async () => {
if (!activeWorkspace?.id || isLoadingMore || logs.length >= totalLogs) return;
setIsLoadingMore(true);
try {
const response = await listWorkspaceLogs(
{
workspace: activeWorkspace.id,
search: filters.search || undefined,
section: filters.section || undefined,
actor: filters.actor || undefined,
event: filters.event || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
ordering: filters.ordering,
},
{ limit: PAGE_SIZE, offset: logs.length },
);
setLogs((current) => [...current, ...(response.results || [])]);
setTotalLogs(response.count || 0);
} catch {
toast.error(t.logs?.loadError || "Failed to load logs.");
} finally {
setIsLoadingMore(false);
}
};
const handleOpenLog = async (id: number) => {
setSelectedLogId(id);
setIsLoadingDetail(true);
try {
const detail = await getWorkspaceLogDetail(id);
setSelectedLog(detail);
} catch {
toast.error(t.logs?.loadDetailsError || "Failed to load log details.");
setSelectedLog(null);
setSelectedLogId(null);
} finally {
setIsLoadingDetail(false);
}
};
const activeFiltersCount = useMemo(
() => [filters.search, filters.section, filters.event, filters.actor, filters.from, filters.to].filter(Boolean).length,
[filters.actor, filters.event, filters.from, filters.search, filters.section, filters.to],
);
const latestActivityLabel = useMemo(() => {
if (!logs[0]?.timestamp) return "-";
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(logs[0].timestamp));
}, [lang, logs]);
const formatNumber = (value: number) =>
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value);
if (!activeWorkspace) {
return (
<div className="p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.logs?.selectWorkspace || "Please select a workspace first."}
</div>
</div>
);
}
if (!isWorkspaceRoleResolved) {
return (
<div className="p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.loading || "Loading..."}
</div>
</div>
);
}
if (!canViewLogs) {
return (
<div className="mx-auto max-w-4xl p-4 sm:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<ShieldCheck className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.logs?.title || "Activity logs"}
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.unauthorized || "Only owners and admins can access workspace activity logs."}
</p>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
<section className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.logs?.title || "Activity logs"}
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.description?.(activeWorkspace.name) || `Review what has happened inside ${activeWorkspace.name}.`}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.totalLogs || "Total logs"}
</span>
<History className="h-4 w-4" />
</div>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{formatNumber(totalLogs)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.activeFilters || "Active filters"}
</span>
<SlidersHorizontal className="h-4 w-4" />
</div>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{formatNumber(activeFiltersCount)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.latestActivity || "Latest activity"}
</span>
<ShieldCheck className="h-4 w-4" />
</div>
<p className="mt-3 text-sm font-semibold text-slate-900 dark:text-white">{latestActivityLabel}</p>
</div>
</div>
</div>
</section>
<LogsFilterBar
value={filters}
users={memberships}
isLoadingUsers={isLoadingUsers}
canSelectUsers={canViewLogs}
onApply={(nextFilters) =>
setSearchParams(
(current) =>
updateQueryParams(
current,
{
search: nextFilters.search,
section: nextFilters.section,
event: nextFilters.event,
actor: nextFilters.actor,
from: nextFilters.from,
to: nextFilters.to,
ordering: nextFilters.ordering,
},
DEFAULT_QUERY_FILTERS,
),
{ replace: true },
)
}
/>
<LogsFeed
items={logs}
total={totalLogs}
hasMore={logs.length < totalLogs}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
selectedId={selectedLogId}
onOpen={(id) => void handleOpenLog(id)}
onLoadMore={() => void handleLoadMore()}
/>
<LogDetailsPanel
open={selectedLogId !== null}
log={selectedLog}
isLoading={isLoadingDetail}
onClose={() => {
setSelectedLogId(null);
setSelectedLog(null);
}}
/>
</div>
);
}