354 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|