Merge pull request 'feat(ui): add activity log page backed by actions_log' (#24) from feature/activity-log-page into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/24
This commit was merged in pull request #24.
This commit is contained in:
297
app/dashboard/activity/page.tsx
Normal file
297
app/dashboard/activity/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { formatDateTime } from "@/lib/dates";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type SearchParams = {
|
||||
page?: string;
|
||||
instance?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function getSingleParam(value: string | string[] | undefined) {
|
||||
if (!value) return "";
|
||||
return Array.isArray(value) ? (value[0] ?? "") : value;
|
||||
}
|
||||
|
||||
function buildQueryString(next: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (value == null) continue;
|
||||
const normalized = String(value);
|
||||
if (!normalized) continue;
|
||||
params.set(key, normalized);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export default async function ActivityPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: SearchParams;
|
||||
}) {
|
||||
const context = await getServerAgencyContextOrRedirect();
|
||||
const supabase = createSupabaseServerClient() as any;
|
||||
|
||||
const selectedInstance = getSingleParam(searchParams?.instance);
|
||||
const selectedAction = getSingleParam(searchParams?.action);
|
||||
const selectedStatus = getSingleParam(searchParams?.status);
|
||||
const page = Math.max(
|
||||
Number(getSingleParam(searchParams?.page) || "1") || 1,
|
||||
1,
|
||||
);
|
||||
|
||||
const { data: instances } = await supabase
|
||||
.from("plesk_instances")
|
||||
.select("id, name, base_url")
|
||||
.eq("agency_id", context.agencyId)
|
||||
.order("name", { ascending: true });
|
||||
|
||||
let baseQuery = supabase
|
||||
.from("actions_log")
|
||||
.select(
|
||||
"id, created_at, target_type, target_id, action, status, error_message, metadata",
|
||||
{ count: "exact" },
|
||||
)
|
||||
.eq("agency_id", context.agencyId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (selectedAction) {
|
||||
baseQuery = baseQuery.eq("action", selectedAction);
|
||||
}
|
||||
|
||||
if (selectedStatus) {
|
||||
baseQuery = baseQuery.eq("status", selectedStatus);
|
||||
}
|
||||
|
||||
if (selectedInstance) {
|
||||
baseQuery = baseQuery.or(
|
||||
`target_id.eq.${selectedInstance},metadata->>plesk_instance_id.eq.${selectedInstance}`,
|
||||
);
|
||||
}
|
||||
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
const { data: logs, count } = await baseQuery.range(from, to);
|
||||
|
||||
const { data: recentForActions } = await supabase
|
||||
.from("actions_log")
|
||||
.select("action")
|
||||
.eq("agency_id", context.agencyId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
const actionOptions = Array.from(
|
||||
new Set((recentForActions ?? []).map((entry: any) => String(entry.action))),
|
||||
).sort();
|
||||
|
||||
const instanceNameById = new Map<string, string>(
|
||||
(instances ?? []).map((instance: any) => [
|
||||
String(instance.id),
|
||||
String(instance.name ?? instance.base_url ?? instance.id),
|
||||
]),
|
||||
);
|
||||
|
||||
const total = count ?? 0;
|
||||
const hasPrev = page > 1;
|
||||
const hasNext = to + 1 < total;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl space-y-4 px-6 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Activity Log</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Recent actions for your agency
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard" className="text-sm text-brand-700 underline">
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-2 rounded-xl border border-slate-200 bg-white p-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600">Instance</label>
|
||||
<select
|
||||
name="instance"
|
||||
defaultValue={selectedInstance}
|
||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All instances</option>
|
||||
{(instances ?? []).map((instance: any) => (
|
||||
<option key={String(instance.id)} value={String(instance.id)}>
|
||||
{String(instance.name ?? instance.base_url ?? instance.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600">Action</label>
|
||||
<select
|
||||
name="action"
|
||||
defaultValue={selectedAction}
|
||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
{actionOptions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600">Status</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={selectedStatus}
|
||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<button className="rounded border px-3 py-1.5 text-sm">Apply</button>
|
||||
<Link
|
||||
href="/dashboard/activity"
|
||||
className="rounded border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Reset
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
||||
<th className="px-3 py-2">Time</th>
|
||||
<th className="px-3 py-2">Instance</th>
|
||||
<th className="px-3 py-2">Action</th>
|
||||
<th className="px-3 py-2">Target</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Message</th>
|
||||
<th className="px-3 py-2">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(logs ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-4 text-slate-500" colSpan={7}>
|
||||
No activity found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(logs ?? []).map((row: any) => {
|
||||
const metadata =
|
||||
row?.metadata && typeof row.metadata === "object"
|
||||
? (row.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const instanceId =
|
||||
(metadata?.plesk_instance_id as string | undefined) ??
|
||||
(row.target_type === "plesk_instance"
|
||||
? (row.target_id as string | null)
|
||||
: null);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-slate-100 align-top"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{formatDateTime(row.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{instanceId
|
||||
? (instanceNameById.get(instanceId) ?? instanceId)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">{row.action ?? "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.target_type ?? "-"}
|
||||
{row.target_id ? ` (${row.target_id})` : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2">{row.status ?? "-"}</td>
|
||||
<td className="px-3 py-2">{row.error_message ?? "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{metadata ? (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-brand-700">
|
||||
View
|
||||
</summary>
|
||||
<pre className="mt-1 max-w-[460px] overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-700">
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-slate-500">
|
||||
Showing {Math.min(total, from + 1)} - {Math.min(total, to + 1)} of{" "}
|
||||
{total}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{hasPrev ? (
|
||||
<Link
|
||||
href={`/dashboard/activity?${buildQueryString({
|
||||
page: page - 1,
|
||||
instance: selectedInstance,
|
||||
action: selectedAction,
|
||||
status: selectedStatus,
|
||||
})}`}
|
||||
className="rounded border px-3 py-1.5"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded border px-3 py-1.5 text-slate-400">
|
||||
Previous
|
||||
</span>
|
||||
)}
|
||||
{hasNext ? (
|
||||
<Link
|
||||
href={`/dashboard/activity?${buildQueryString({
|
||||
page: page + 1,
|
||||
instance: selectedInstance,
|
||||
action: selectedAction,
|
||||
status: selectedStatus,
|
||||
})}`}
|
||||
className="rounded border px-3 py-1.5"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded border px-3 py-1.5 text-slate-400">
|
||||
Next
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DashboardControls } from "@/components/dashboard/dashboard-controls";
|
||||
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import Link from "next/link";
|
||||
|
||||
type AgencySummary = Pick<
|
||||
Database["public"]["Tables"]["agencies"]["Row"],
|
||||
@@ -238,7 +239,15 @@ export default async function DashboardPage() {
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">Role: {context.role}</p>
|
||||
</div>
|
||||
<SignOutButton />
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard/activity"
|
||||
className="text-sm text-brand-700 underline"
|
||||
>
|
||||
Activity
|
||||
</Link>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
|
||||
Reference in New Issue
Block a user