Files
stack/apps/web/src/app/(authenticated)/logs/page.tsx
Jason Woltje d361d00674
Some checks failed
ci/woodpecker/push/ci Pipeline failed
fix: Logs page — activity_logs, optional workspaceId, autoRefresh on (#637)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 22:10:16 +00:00

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>
);
}