2026-08-14 09:40:03 +12:00
import { useMemo , useState } from 'react' ;
import { Link } from 'react-router-dom' ;
import { query } from '../api/client' ;
import { useQuery } from '../lib/hooks' ;
import { daysAgo , num , when } from '../lib/format' ;
import {
Banner ,
Button ,
Card ,
Empty ,
EmptyRow ,
Field ,
Grid ,
Loading ,
PageHead ,
Segments ,
TableWrap ,
Tag ,
Tiles ,
} from '../components/ui' ;
import { Bars } from '../components/ui' ;
import type { LoginDevicesResponse , LoginsResponse } from '../api/types' ;
/* The sign-in history.
*
* This is the page the whole login-analytics feature exists for, and its shape follows
* from one observation: an operator arrives here with a *question*, not a browsing
* intention — "did the bedroom TV connect this morning", "who is that address", "why does
* this account keep failing" — so the filter bar is above the table and always visible,
* not behind a disclosure, and every control in it maps to one server-side filter.
*
* Two tables of the same rows, deliberately, the stance the searches page takes: the
* devices summary answers "which televisions are connecting", which is what you read when
* you do not yet know where to look, and the log is uncollapsed and newest-first, which is
* what you read when something has just happened. */
type View = 'log' | 'devices' ;
interface Filters {
user : string ;
q : string ;
ip : string ;
outcome : '' | 'success' | 'failure' ;
from : string ;
to : string ;
}
const EMPTY : Filters = { user : '' , q : '' , ip : '' , outcome : '' , from : '' , to : '' };
const WINDOWS = [
{ value : 1 , label : 'Today' },
{ value : 7 , label : '7 days' },
{ value : 30 , label : '30 days' },
{ value : 0 , label : 'All' },
] as const ;
export function LoginsPage() {
const [ view , setView ] = useState < View >( 'log' );
const [ days , setDays ] = useState < number >( 7 );
const [ filters , setFilters ] = useState < Filters >( EMPTY );
const [ page , setPage ] = useState ( 0 );
const limit = 100 ;
// The window and the explicit date range are the same filter expressed two ways, and an
// explicit `from` wins: an operator who typed a date meant it, and silently narrowing it
// to the last seven days would answer a question they did not ask.
const params = useMemo (
() =>
query ({
... filters ,
days : filters.from ? undefined : days || undefined ,
limit ,
offset : page * limit ,
}),
[ filters , days , page ],
);
const log = useQuery < LoginsResponse >( `/admin/api/logins ${ params } ` , { enabled : view === 'log' });
const devices = useQuery < LoginDevicesResponse >( `/admin/api/logins/devices ${ params } ` , {
enabled : view === 'devices' ,
});
const users = log . data ? . users ?? devices . data ? . users ?? [];
const totals = log . data ? . totals ?? devices . data ? . totals ;
const retention = log . data ? . retentionDays ?? devices . data ? . retentionDays ?? 90 ;
const loading = view === 'log' ? log.loading : devices.loading ;
const error = view === 'log' ? log.error : devices.error ;
const update = ( patch : Partial < Filters >) => {
setFilters (( current ) => ({ ... current , ... patch }));
setPage ( 0 );
};
const active =
Object . entries ( filters ). some (([, value ]) => value !== '' ) || Boolean ( filters . from );
return (
<>
< PageHead
title = "Sign-in history"
intro = "Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected."
actions = {
< Segments
value = { view }
options = {[
{ value : 'log' , label : 'Log' },
{ value : 'devices' , label : 'By device' },
]}
onChange = { setView }
/>
}
/>
< Banner message = { error } />
{ totals ? (
< Tiles
tiles = {[
{ label : 'Successful sign-ins' , value : num ( totals . logins ), icon : 'key' , tone : 'ok' },
{
label : 'Refused' ,
value : num ( totals . failures ),
icon : 'shield' ,
tone : totals.failures > 0 ? 'warn' : undefined ,
},
{ label : 'Televisions' , value : num ( totals . devices ), icon : 'tv' , tone : 'info' },
{ label : 'People' , value : num ( totals . users ), icon : 'people' , tone : 'note' },
{ label : 'Addresses' , value : num ( totals . addresses ), icon : 'globe' , tone : 'data' },
{
label : 'History kept' ,
value : ` ${ retention } days` ,
small : true ,
icon : 'clock' ,
},
]}
/>
) : null }
{ /* Fast filtering is most of what makes this table useful, so the controls sit above
it rather than behind a "filters" disclosure nobody opens. */ }
< div className = "filters" >
< Field label = "Window" >
< Segments
value = { filters . from ? - 1 : days }
options = { WINDOWS . map (( entry ) => ({ value : entry.value as number , label : entry.label }))}
onChange = {( next ) => {
setDays ( next );
update ({ from : '' , to : '' });
}}
/>
</ Field >
< Field label = "Person" >
< select value = { filters . user } onChange = {( event ) => update ({ user : event.target.value })}>
< option value = "" > Anyone </ option >
{ users . map (( user ) => (
< option key = { user . id } value = { user . id }>
{ user . username || user . id }
</ option >
))}
</ select >
</ Field >
< Field label = "Outcome" >
< select
value = { filters . outcome }
onChange = {( event ) => update ({ outcome : event.target.value as Filters [ 'outcome' ] })}
>
< option value = "" > Both </ option >
< option value = "success" > Got in </ option >
< option value = "failure" > Refused </ option >
</ select >
</ Field >
< Field label = "Address" >
< input
type = "text"
value = { filters . ip }
placeholder = "10.0.0.4"
onChange = {( event ) => update ({ ip : event.target.value })}
/>
</ Field >
< Field label = "From" >
< input type = "date" value = { filters . from } onChange = {( event ) => update ({ from : event . target . value })} />
</ Field >
< Field label = "To" >
< input type = "date" value = { filters . to } onChange = {( event ) => update ({ to : event.target.value })} />
</ Field >
< Field label = "Search" grow >
< input
type = "search"
value = { filters . q }
placeholder = "Name, device or address"
onChange = {( event ) => update ({ q : event.target.value })}
/>
</ Field >
< div className = "filter-actions" >
{ active ? (
< Button
variant = "quiet"
size = "sm"
onClick = {() => {
setFilters ( EMPTY );
setPage ( 0 );
}}
>
Clear
</ Button >
) : null }
</ div >
</ div >
{ loading ? (
< Loading />
) : view === 'log' ? (
< LogView data = { log . data } page = { page } limit = { limit } onPage = { setPage } />
) : (
< DeviceView data = { devices . data } />
)}
</>
);
}
function LogView ({
data ,
page ,
limit ,
onPage ,
} : {
data : LoginsResponse | undefined ;
page : number ;
limit : number ;
onPage : ( next : number ) => void ;
}) {
if ( ! data ) return null ;
const shown = data . events . length ;
const from = data . total === 0 ? 0 : page * limit + 1 ;
return (
<>
< Grid cols = "wide" >
< Card
title = "Attempts per day"
2026-08-19 06:57:59 +12:00
intro = "Grouped in the server's own timezone, so an evening sign-in stays on the day it happened."
2026-08-14 09:40:03 +12:00
icon = "chart"
tone = "info"
>
< Bars
data = { data . days }
labelOf = {( row : ( typeof data . days )[ number ]) => row . day }
valueOf = {( row : ( typeof data . days )[ number ]) => row . logins + row . failures }
toneOf = {( row : ( typeof data . days )[ number ]) => ( row . failures > row . logins ? 'bad' : undefined )}
title = {( row : ( typeof data . days )[ number ]) =>
` ${ row . day } : ${ row . logins } in, ${ row . failures } refused, ${ row . devices } televisions`
}
/>
</ Card >
< Card title = "Where from" icon = "globe" tone = "data" >
{ data . addresses . length === 0 ? (
< Empty > No addresses in this window .</ Empty >
) : (
< div className = "list" >
{ data . addresses . slice ( 0 , 8 ). map (( address ) => (
< div className = "list-item" key = { address . ipAddress }>
< div className = "list-body" >
< b className = "mono" >{ address . ipAddress }</ b >
< p >
{ num ( address . logins )} in
{ address . failures > 0 ? ` · ${ num ( address . failures ) } refused` : '' }
</ p >
</ div >
{ address . failures > 0 && address . logins === 0 ? < Tag tone = "bad" > only refused </ Tag > : null }
</ div >
))}
</ div >
)}
</ Card >
</ Grid >
< Card
title = "Attempts"
intro = "Uncollapsed and newest first: this is what to read when somebody says a television will not sign in."
icon = "key"
tone = "ok"
actions = {
< span className = "filter-summary" >
{ data . total === 0
? 'nothing matches'
: ` ${ num ( from ) } – ${ num ( from + shown - 1 ) } of ${ num ( data . total ) } ` }
</ span >
}
footer = {
data . total > limit ? (
<>
< Button size = "sm" disabled = { page === 0 } onClick = {() => onPage ( page - 1 )}>
Newer
</ Button >
< Button
size = "sm"
disabled = {( page + 1 ) * limit >= data . total }
onClick = {() => onPage ( page + 1 )}
>
Older
</ Button >
</>
) : undefined
}
>
< TableWrap >
< table >
< thead >
< tr >
< th className = "nowrap" > When </ th >
< th > Person </ th >
< th > Television </ th >
< th className = "nowrap" > Address </ th >
< th > Build </ th >
< th > Outcome </ th >
</ tr >
</ thead >
< tbody >
{ data . events . length === 0 ? (
< EmptyRow columns = { 6 }> No sign - in attempts match these filters .</ EmptyRow >
) : (
data . events . map (( event ) => (
< tr key = { event . id }>
< td className = "nowrap muted" >{ when ( event . occurredAt )}</ td >
< td >{ event . username || < span className = "quiet" > unknown </ span >}</ td >
< td >
{ event . deviceId ? (
< Link className = "table-row-link" to = { `/admin/devices/ ${ encodeURIComponent ( event . deviceId ) } ` }>
{ event . deviceName || event . deviceId }
</ Link >
) : (
< span className = "quiet" > — </ span >
)}
</ td >
< td className = "mono nowrap" >{ event . ipAddress || '—' }</ td >
< td className = "mono" >{ event . clientVersion || '—' }</ td >
< td className = "nowrap" >
{ event . success ? (
event . newDevice ? (
< Tag tone = "info" > first sign - in </ Tag >
) : (
< Tag tone = "ok" > got in </ Tag >
)
) : (
< Tag tone = "bad" >{ event . failureReason || 'refused' }</ Tag >
)}
</ td >
</ tr >
))
)}
</ tbody >
</ table >
</ TableWrap >
</ Card >
</>
);
}
function DeviceView ({ data } : { data : LoginDevicesResponse | undefined }) {
if ( ! data ) return null ;
return (
< Card
title = "Televisions"
intro = "Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it."
icon = "tv"
tone = "info"
>
< TableWrap >
< table >
< thead >
< tr >
< th > Television </ th >
< th > Person </ th >
< th className = "num" > Today </ th >
< th className = "num" > Sign - ins </ th >
< th className = "num" > Refused </ th >
< th className = "num" > Addresses </ th >
< th className = "nowrap" > Last address </ th >
< th className = "nowrap" > Last sign - in </ th >
< th > Build </ th >
</ tr >
</ thead >
< tbody >
{ data . devices . length === 0 ? (
< EmptyRow columns = { 9 }> No television has connected in this window .</ EmptyRow >
) : (
data . devices . map (( device ) => (
< tr key = { device . deviceId }>
< td >
< Link className = "table-row-link" to = { `/admin/devices/ ${ encodeURIComponent ( device . deviceId ) } ` }>
{ device . deviceName || device . deviceId }
</ Link >
</ td >
< td className = "muted" >{ device . username || '—' }</ td >
< td className = "num" >{ device . loginsToday > 0 ? num ( device . loginsToday ) : '—' }</ td >
< td className = "num" >{ num ( device . logins )}</ td >
< td className = "num" >
{ device . failures > 0 ? < span className = "mono" >{ num ( device . failures )}</ span > : '—' }
</ td >
< td className = "num" >{ num ( device . distinctIps )}</ td >
< td className = "mono nowrap" >{ device . lastIp || '—' }</ td >
{ /* A device with failures and no successes has no last sign-in, which is
a real answer rather than a zero one. */ }
< td className = "nowrap muted" >{ device . lastLogin ? when ( device . lastLogin ) : '—' }</ td >
< td className = "mono" >{ device . clientVersion || '—' }</ td >
</ tr >
))
)}
</ tbody >
</ table >
</ TableWrap >
</ Card >
);
}
/** Exported for the device page, which offers the same seven-day default. */
export const defaultWindowFrom = () => daysAgo ( 7 );