0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { num, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
EmptyRow,
|
||||
Field,
|
||||
Loading,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
|
||||
interface SearchTerm {
|
||||
query: string;
|
||||
searches: number;
|
||||
viewers: number;
|
||||
lastAt: string;
|
||||
}
|
||||
|
||||
interface SearchEvent {
|
||||
occurredAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface SearchesResponse {
|
||||
days: number;
|
||||
retentionDays: number;
|
||||
totals: { searches: number; queries: number; viewers: number };
|
||||
terms: SearchTerm[] | null;
|
||||
recent: SearchEvent[] | null;
|
||||
}
|
||||
|
||||
/* Two tables of the same rows on purpose: the summary groups by query and answers "what
|
||||
does this house look for", which is what a library is organised against; the log is
|
||||
uncollapsed and newest-first and answers "what happened just now", which is the one to
|
||||
read when somebody reports that search is not finding something. */
|
||||
|
||||
export function SearchesPage() {
|
||||
const [days, setDays] = useState(7);
|
||||
const { data, error, loading } = useQuery<SearchesResponse>(`/admin/api/searches?days=${days}`);
|
||||
const terms = data?.terms ?? [];
|
||||
const recent = data?.recent ?? [];
|
||||
const totals = data?.totals;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Searches"
|
||||
intro="What the household has been looking for, and what it searched just now."
|
||||
/>
|
||||
<Banner message={error} />
|
||||
|
||||
<Tiles
|
||||
tiles={[
|
||||
{ label: 'searches', value: num(totals?.searches ?? 0), icon: 'search', tone: 'info' },
|
||||
{ label: 'distinct queries', value: num(totals?.queries ?? 0), icon: 'list', tone: 'data' },
|
||||
{ label: 'viewers searching', value: num(totals?.viewers ?? 0), icon: 'people', tone: 'note' },
|
||||
// Stated rather than assumed: every figure on this page is bounded by how long
|
||||
// the table keeps a row, and an operator reading a quiet week has no other way to
|
||||
// tell a household that stopped searching from one whose history has aged out.
|
||||
{
|
||||
label: 'history kept',
|
||||
value: `${data?.retentionDays ?? 30} days`,
|
||||
small: true,
|
||||
icon: 'clock',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
title="What the house looks for"
|
||||
intro="Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too."
|
||||
icon="search"
|
||||
tone="info"
|
||||
actions={
|
||||
<Field label="Window">
|
||||
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
|
||||
<option value={1}>24 hours</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
</select>
|
||||
</Field>
|
||||
}
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Query</th>
|
||||
<th className="num">Searches</th>
|
||||
<th className="num">Viewers</th>
|
||||
<th>Last searched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terms.length === 0 ? (
|
||||
<EmptyRow columns={4}>Nothing searched in this window.</EmptyRow>
|
||||
) : (
|
||||
terms.map((term) => (
|
||||
<tr key={term.query}>
|
||||
<td>{term.query}</td>
|
||||
<td className="num">{num(term.searches)}</td>
|
||||
<td className="num">{num(term.viewers)}</td>
|
||||
<td className="muted nowrap">{when(term.lastAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="As it happened"
|
||||
intro="The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something."
|
||||
icon="history"
|
||||
tone="note"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Viewer</th>
|
||||
<th>Query</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyRow columns={3}>No searches in this window.</EmptyRow>
|
||||
) : (
|
||||
recent.map((event, index) => (
|
||||
<tr key={`${event.occurredAt}:${index}`}>
|
||||
<td className="muted nowrap">{when(event.occurredAt)}</td>
|
||||
{/* An unattributed search keeps its row and shows the id: the query
|
||||
is the point, and a viewer whose sessions have all expired is
|
||||
still one searcher rather than nobody. */}
|
||||
<td>
|
||||
{event.username || <Tag tone="warn">{event.userId || 'unknown'}</Tag>}
|
||||
</td>
|
||||
<td>{event.query}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user