Records every successful document download and surfaces the counts in the admin area, so the docket owner can see which filings are being pulled and how often. - document_accesses table + DocumentAccess model - DocumentController@download records each successful download with IP and user agent - documents:import-access-log backfills history from an archived nginx log - Dashboard gains a 60-day downloads card and a most-requested list - New Document Access page with a date-range filter and per-day trend Client IPs were only captured from 2026-07-30, when the reverse proxy chain started forwarding X-Forwarded-For correctly. Rows imported from the old nginx log therefore have no IP, and the UI labels those periods as download counts rather than unique visitors instead of showing a misleading number.
205 lines
9.1 KiB
Vue
205 lines
9.1 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, router } from '@inertiajs/vue3';
|
|
import { computed, ref } from 'vue';
|
|
|
|
interface DocumentRow {
|
|
id: number;
|
|
title: string;
|
|
downloads: number;
|
|
unique_visitors: number;
|
|
last_accessed: string;
|
|
}
|
|
|
|
interface DailyRow {
|
|
day: string;
|
|
downloads: number;
|
|
}
|
|
|
|
interface Props {
|
|
documents: DocumentRow[];
|
|
daily: DailyRow[];
|
|
filters: { from: string; to: string; include_bots: boolean };
|
|
totals: { downloads: number; documents_touched: number };
|
|
ip_data_starts: string;
|
|
range_predates_ip_data: boolean;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
const from = ref(props.filters.from);
|
|
const to = ref(props.filters.to);
|
|
const includeBots = ref(props.filters.include_bots);
|
|
|
|
const apply = () => {
|
|
router.get(
|
|
route('admin.document-access.index'),
|
|
{ from: from.value, to: to.value, include_bots: includeBots.value },
|
|
{ preserveState: true, preserveScroll: true },
|
|
);
|
|
};
|
|
|
|
const preset = (days: number) => {
|
|
const end = new Date();
|
|
const start = new Date();
|
|
start.setDate(start.getDate() - days);
|
|
from.value = start.toISOString().slice(0, 10);
|
|
to.value = end.toISOString().slice(0, 10);
|
|
apply();
|
|
};
|
|
|
|
// Scale the inline bar chart to the busiest day in range.
|
|
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-gray-100">
|
|
<Head title="Document Access" />
|
|
|
|
<header class="bg-white shadow">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">Document Access</h1>
|
|
<p class="text-sm text-gray-600 mt-1">
|
|
How often each document has been downloaded
|
|
</p>
|
|
</div>
|
|
<Link
|
|
:href="route('admin.dashboard')"
|
|
class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 transition-colors"
|
|
>
|
|
← Back to Dashboard
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Filters -->
|
|
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div class="flex flex-wrap items-end gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">From</label>
|
|
<input
|
|
v-model="from"
|
|
type="date"
|
|
class="border-gray-300 rounded-lg shadow-sm text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">To</label>
|
|
<input
|
|
v-model="to"
|
|
type="date"
|
|
class="border-gray-300 rounded-lg shadow-sm text-sm"
|
|
/>
|
|
</div>
|
|
<button
|
|
@click="apply"
|
|
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
Apply
|
|
</button>
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-for="p in [30, 60, 90]"
|
|
:key="p"
|
|
@click="preset(p)"
|
|
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
Last {{ p }}d
|
|
</button>
|
|
</div>
|
|
<label class="flex items-center gap-2 ml-auto text-sm text-gray-700">
|
|
<input
|
|
v-model="includeBots"
|
|
type="checkbox"
|
|
@change="apply"
|
|
class="rounded border-gray-300"
|
|
/>
|
|
Include crawlers
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<p class="text-sm font-medium text-gray-600">Total downloads</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Caveat: unique visitors are not knowable before IP logging began -->
|
|
<div
|
|
v-if="props.range_predates_ip_data"
|
|
class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"
|
|
>
|
|
<p class="text-sm text-amber-900">
|
|
<span class="font-semibold">Downloads, not unique visitors.</span>
|
|
Visitor IP addresses were only recorded from
|
|
{{ props.ip_data_starts }} onward. For any earlier period the same
|
|
person downloading a document ten times counts as ten downloads, and
|
|
the unique-visitor column will read 0.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Daily trend -->
|
|
<div v-if="props.daily.length > 0" class="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h2 class="text-xl font-bold text-gray-900 mb-4">Downloads per day</h2>
|
|
<div class="flex items-end gap-1 h-40 overflow-x-auto">
|
|
<div
|
|
v-for="d in props.daily"
|
|
:key="d.day"
|
|
class="flex-1 min-w-[8px] bg-blue-500 hover:bg-blue-600 rounded-t transition-colors"
|
|
:style="{ height: (d.downloads / peak) * 100 + '%' }"
|
|
:title="`${d.day}: ${d.downloads} download${d.downloads !== 1 ? 's' : ''}`"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
<span>{{ props.daily[0]?.day }}</span>
|
|
<span>peak {{ peak }}/day</span>
|
|
<span>{{ props.daily[props.daily.length - 1]?.day }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-document table -->
|
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-gray-200">
|
|
<h2 class="text-xl font-bold text-gray-900">By document</h2>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Document</th>
|
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
|
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique visitors</th>
|
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Last accessed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
<tr v-for="doc in props.documents" :key="doc.id" class="hover:bg-gray-50">
|
|
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ doc.title }}</td>
|
|
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ doc.downloads }}</td>
|
|
<td class="px-6 py-4 text-sm text-right" :class="doc.unique_visitors === 0 ? 'text-gray-400' : 'text-gray-900'">
|
|
{{ doc.unique_visitors === 0 ? '—' : doc.unique_visitors }}
|
|
</td>
|
|
<td class="px-6 py-4 text-sm text-gray-600 text-right">{{ doc.last_accessed }}</td>
|
|
</tr>
|
|
<tr v-if="props.documents.length === 0">
|
|
<td colspan="4" class="px-6 py-8 text-center text-gray-500">
|
|
No downloads recorded in this date range.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|