Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import type { ReactElement } from "react";
|
|
|
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
import {
|
|
fetchActivityLogs,
|
|
ActivityAction,
|
|
EntityType,
|
|
type ActivityLog,
|
|
type ActivityLogFilters,
|
|
} from "@/lib/api/activity";
|
|
import { useWorkspaceId } from "@/lib/hooks";
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────
|
|
|
|
type ActionFilter = "all" | ActivityAction;
|
|
type EntityFilter = "all" | EntityType;
|
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
|
|
|
const ACTION_OPTIONS: { value: ActionFilter; label: string }[] = [
|
|
{ value: "all", label: "All actions" },
|
|
{ value: ActivityAction.CREATED, label: "Created" },
|
|
{ value: ActivityAction.UPDATED, label: "Updated" },
|
|
{ value: ActivityAction.DELETED, label: "Deleted" },
|
|
{ value: ActivityAction.COMPLETED, label: "Completed" },
|
|
{ value: ActivityAction.ASSIGNED, label: "Assigned" },
|
|
];
|
|
|
|
const ENTITY_OPTIONS: { value: EntityFilter; label: string }[] = [
|
|
{ value: "all", label: "All entities" },
|
|
{ value: EntityType.TASK, label: "Tasks" },
|
|
{ value: EntityType.EVENT, label: "Events" },
|
|
{ value: EntityType.PROJECT, label: "Projects" },
|
|
{ value: EntityType.WORKSPACE, label: "Workspaces" },
|
|
{ value: EntityType.USER, label: "Users" },
|
|
{ value: EntityType.DOMAIN, label: "Domains" },
|
|
{ value: EntityType.IDEA, label: "Ideas" },
|
|
];
|
|
|
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
|
{ value: "24h", label: "Last 24h" },
|
|
{ value: "7d", label: "7d" },
|
|
{ value: "30d", label: "30d" },
|
|
{ value: "all", label: "All" },
|
|
];
|
|
|
|
const POLL_INTERVAL_MS = 5_000;
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
const ACTION_COLORS: Record<string, string> = {
|
|
[ActivityAction.CREATED]: "var(--ms-teal-400)",
|
|
[ActivityAction.UPDATED]: "var(--ms-blue-400)",
|
|
[ActivityAction.DELETED]: "var(--danger)",
|
|
[ActivityAction.COMPLETED]: "var(--ms-emerald-400)",
|
|
[ActivityAction.ASSIGNED]: "var(--ms-amber-400)",
|
|
};
|
|
|
|
function getActionColor(action: string): string {
|
|
return ACTION_COLORS[action] ?? "var(--muted)";
|
|
}
|
|
|
|
const ENTITY_LABELS: Record<string, string> = {
|
|
[EntityType.TASK]: "Task",
|
|
[EntityType.EVENT]: "Event",
|
|
[EntityType.PROJECT]: "Project",
|
|
[EntityType.WORKSPACE]: "Workspace",
|
|
[EntityType.USER]: "User",
|
|
[EntityType.DOMAIN]: "Domain",
|
|
[EntityType.IDEA]: "Idea",
|
|
};
|
|
|
|
function getEntityTypeLabel(entityType: string): string {
|
|
return ENTITY_LABELS[entityType] ?? entityType;
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = Date.now();
|
|
const diffMs = now - date.getTime();
|
|
const diffSec = Math.floor(diffMs / 1_000);
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
const diffDay = Math.floor(diffHr / 24);
|
|
|
|
if (diffSec < 60) return "just now";
|
|
if (diffMin < 60) return `${String(diffMin)}m ago`;
|
|
if (diffHr < 24) return `${String(diffHr)}h ago`;
|
|
if (diffDay < 30) return `${String(diffDay)}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
|
if (range === "all") return true;
|
|
const date = new Date(dateStr);
|
|
const now = Date.now();
|
|
const hours = range === "24h" ? 24 : range === "7d" ? 168 : 720;
|
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
|
}
|
|
|
|
// ─── Action Badge ─────────────────────────────────────────────────────
|
|
|
|
function ActionBadge({ action }: { action: string }): ReactElement {
|
|
const color = getActionColor(action);
|
|
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
padding: "2px 10px",
|
|
borderRadius: 9999,
|
|
fontSize: "0.75rem",
|
|
fontWeight: 600,
|
|
color,
|
|
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
|
textTransform: "capitalize",
|
|
}}
|
|
>
|
|
{action.toLowerCase()}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page Component ──────────────────────────────────────────────
|
|
|
|
export default function LogsPage(): ReactElement {
|
|
const workspaceId = useWorkspaceId();
|
|
|
|
// Data state
|
|
const [activities, setActivities] = useState<ActivityLog[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Filters
|
|
const [actionFilter, setActionFilter] = useState<ActionFilter>("all");
|
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
// Auto-refresh
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// ─── Data Loading ─────────────────────────────────────────────────
|
|
|
|
const loadActivities = useCallback(async (): Promise<void> => {
|
|
try {
|
|
const filters: ActivityLogFilters = {};
|
|
if (workspaceId) {
|
|
filters.workspaceId = workspaceId;
|
|
}
|
|
if (actionFilter !== "all") {
|
|
filters.action = actionFilter;
|
|
}
|
|
if (entityFilter !== "all") {
|
|
filters.entityType = entityFilter;
|
|
}
|
|
|
|
const response: Awaited<ReturnType<typeof fetchActivityLogs>> =
|
|
await fetchActivityLogs(filters);
|
|
setActivities(response);
|
|
setError(null);
|
|
} catch (err: unknown) {
|
|
console.error("[Logs] Failed to fetch activity logs:", err);
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "We had trouble loading activity logs. Please try again when you're ready."
|
|
);
|
|
}
|
|
}, [workspaceId, actionFilter, entityFilter]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setIsLoading(true);
|
|
|
|
loadActivities()
|
|
.then(() => {
|
|
if (!cancelled) {
|
|
setIsLoading(false);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setIsLoading(false);
|
|
}
|
|
});
|
|
|
|
return (): void => {
|
|
cancelled = true;
|
|
};
|
|
}, [loadActivities]);
|
|
|
|
// Auto-refresh polling
|
|
useEffect(() => {
|
|
if (autoRefresh) {
|
|
intervalRef.current = setInterval(() => {
|
|
void loadActivities();
|
|
}, POLL_INTERVAL_MS);
|
|
} else if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
|
|
return (): void => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, [autoRefresh, loadActivities]);
|
|
|
|
// ─── Filtering ────────────────────────────────────────────────────
|
|
|
|
const filteredActivities = activities.filter((activity) => {
|
|
// Date range filter
|
|
if (!isWithinDateRange(activity.createdAt, dateRange)) return false;
|
|
|
|
// Search filter
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
const matchesEntity = getEntityTypeLabel(activity.entityType).toLowerCase().includes(q);
|
|
const matchesId = activity.entityId.toLowerCase().includes(q);
|
|
const matchesUser = activity.user?.name?.toLowerCase().includes(q);
|
|
const matchesEmail = activity.user?.email.toLowerCase().includes(q);
|
|
if (!matchesEntity && !matchesId && !matchesUser && !matchesEmail) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// ─── Manual Refresh ───────────────────────────────────────────────
|
|
|
|
const handleManualRefresh = (): void => {
|
|
setIsLoading(true);
|
|
void loadActivities().finally(() => {
|
|
setIsLoading(false);
|
|
});
|
|
};
|
|
|
|
const handleRetry = (): void => {
|
|
setError(null);
|
|
handleManualRefresh();
|
|
};
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<main className="container mx-auto px-4 py-8">
|
|
{/* Pulse animation for auto-refresh */}
|
|
<style>{`
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
`}</style>
|
|
|
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 16,
|
|
marginBottom: 32,
|
|
}}
|
|
>
|
|
<div>
|
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
|
Activity Logs
|
|
</h1>
|
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
|
Audit trail and activity history
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
{/* Auto-refresh toggle */}
|
|
<button
|
|
onClick={() => {
|
|
setAutoRefresh((prev) => !prev);
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "8px 14px",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
border: `1px solid ${autoRefresh ? "var(--ms-teal-400)" : "var(--border)"}`,
|
|
background: autoRefresh
|
|
? "color-mix(in srgb, var(--ms-teal-400) 12%, transparent)"
|
|
: "var(--surface)",
|
|
color: autoRefresh ? "var(--ms-teal-400)" : "var(--text-muted)",
|
|
transition: "all 150ms ease",
|
|
}}
|
|
>
|
|
{autoRefresh && (
|
|
<span
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: "var(--ms-teal-400)",
|
|
animation: "pulse 1.5s ease-in-out infinite",
|
|
}}
|
|
/>
|
|
)}
|
|
Auto-refresh {autoRefresh ? "on" : "off"}
|
|
</button>
|
|
|
|
{/* Manual refresh */}
|
|
<button
|
|
onClick={handleManualRefresh}
|
|
disabled={isLoading}
|
|
style={{
|
|
padding: "8px 14px",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
fontWeight: 500,
|
|
cursor: isLoading ? "not-allowed" : "pointer",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface)",
|
|
color: "var(--text-muted)",
|
|
opacity: isLoading ? 0.5 : 1,
|
|
transition: "all 150ms ease",
|
|
}}
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Filter Bar ─────────────────────────────────────────── */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
{/* Action filter */}
|
|
<select
|
|
value={actionFilter}
|
|
onChange={(e) => {
|
|
setActionFilter(e.target.value as ActionFilter);
|
|
}}
|
|
style={{
|
|
padding: "8px 12px",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface)",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
minWidth: 140,
|
|
}}
|
|
>
|
|
{ACTION_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Entity filter */}
|
|
<select
|
|
value={entityFilter}
|
|
onChange={(e) => {
|
|
setEntityFilter(e.target.value as EntityFilter);
|
|
}}
|
|
style={{
|
|
padding: "8px 12px",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface)",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
minWidth: 140,
|
|
}}
|
|
>
|
|
{ENTITY_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Date range tabs */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
borderRadius: 8,
|
|
overflow: "hidden",
|
|
border: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
{DATE_RANGES.map((range) => (
|
|
<button
|
|
key={range.value}
|
|
onClick={() => {
|
|
setDateRange(range.value);
|
|
}}
|
|
style={{
|
|
padding: "8px 14px",
|
|
fontSize: "0.82rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
border: "none",
|
|
borderRight: "1px solid var(--border)",
|
|
background: dateRange === range.value ? "var(--primary)" : "var(--surface)",
|
|
color: dateRange === range.value ? "#fff" : "var(--text-muted)",
|
|
transition: "all 150ms ease",
|
|
}}
|
|
>
|
|
{range.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search by entity or user..."
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
}}
|
|
style={{
|
|
padding: "8px 12px",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface)",
|
|
color: "var(--text)",
|
|
minWidth: 200,
|
|
flex: "1 1 200px",
|
|
maxWidth: 320,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* ─── Content ────────────────────────────────────────────── */}
|
|
{isLoading && activities.length === 0 ? (
|
|
<div className="flex justify-center py-16">
|
|
<MosaicSpinner label="Loading activity logs..." />
|
|
</div>
|
|
) : error !== null ? (
|
|
<div
|
|
className="rounded-lg p-6 text-center"
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
|
<button
|
|
onClick={handleRetry}
|
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
|
style={{ background: "var(--danger)", cursor: "pointer", border: "none" }}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : filteredActivities.length === 0 ? (
|
|
<div
|
|
className="rounded-lg p-8 text-center"
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--text-muted)" }}>No activity logs found</p>
|
|
</div>
|
|
) : (
|
|
/* ─── Activity Table ──────────────────────────────────────── */
|
|
<div
|
|
style={{
|
|
borderRadius: 12,
|
|
border: "1px solid var(--border)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div style={{ overflowX: "auto" }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead>
|
|
<tr
|
|
style={{
|
|
background: "var(--bg-mid)",
|
|
}}
|
|
>
|
|
{["Action", "Entity", "User", "Details", "Time"].map((header) => (
|
|
<th
|
|
key={header}
|
|
style={{
|
|
padding: "10px 16px",
|
|
textAlign: "left",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 600,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
color: "var(--muted)",
|
|
fontFamily: "var(--mono)",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredActivities.map((activity) => (
|
|
<ActivityRow key={activity.id} activity={activity} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// ─── Activity Row Component ───────────────────────────────────────────
|
|
|
|
function ActivityRow({ activity }: { activity: ActivityLog }): ReactElement {
|
|
return (
|
|
<tr
|
|
style={{
|
|
background: "var(--surface)",
|
|
borderBottom: "1px solid var(--border)",
|
|
transition: "background 100ms ease",
|
|
}}
|
|
>
|
|
<td style={{ padding: "12px 16px" }}>
|
|
<ActionBadge action={activity.action} />
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "12px 16px",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<span>{getEntityTypeLabel(activity.entityType)}</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
color: "var(--muted)",
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
{activity.entityId}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "12px 16px",
|
|
fontSize: "0.82rem",
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{activity.user ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<span>{activity.user.name ?? activity.user.email}</span>
|
|
{activity.user.name && (
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
color: "var(--muted)",
|
|
}}
|
|
>
|
|
{activity.user.email}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span style={{ color: "var(--muted)" }}>—</span>
|
|
)}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "12px 16px",
|
|
fontSize: "0.78rem",
|
|
color: "var(--text-muted)",
|
|
fontFamily: "var(--mono)",
|
|
maxWidth: 300,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
title={activity.details ? JSON.stringify(activity.details) : undefined}
|
|
>
|
|
{activity.details ? JSON.stringify(activity.details) : "—"}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "12px 16px",
|
|
fontSize: "0.82rem",
|
|
fontFamily: "var(--mono)",
|
|
color: "var(--text-muted)",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{formatRelativeTime(activity.createdAt)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|