feat(logs): add workspace activity log page
This commit is contained in:
312
src/pages/Logs.tsx
Normal file
312
src/pages/Logs.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
|
||||
|
||||
const DEFAULT_FILTERS: LogsFilterDraft = {
|
||||
search: "",
|
||||
section: "",
|
||||
event: "",
|
||||
actor: "",
|
||||
from: "",
|
||||
to: "",
|
||||
ordering: "-timestamp",
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function Logs() {
|
||||
const { t, lang } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const [filters, setFilters] = useState<LogsFilterDraft>(DEFAULT_FILTERS);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
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={setFilters}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user