M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity #105
+49
-7
@@ -102,6 +102,7 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
launch { collectServerHealth() }
|
launch { collectServerHealth() }
|
||||||
launch { collectUpnpDrops() }
|
launch { collectUpnpDrops() }
|
||||||
launch { collectPlayerState() }
|
launch { collectPlayerState() }
|
||||||
|
launch { collectTrackChanges() }
|
||||||
launch { collectRoutes() }
|
launch { collectRoutes() }
|
||||||
launch { heartbeatLoop() }
|
launch { heartbeatLoop() }
|
||||||
}
|
}
|
||||||
@@ -147,11 +148,13 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun collectPlayerState() {
|
private suspend fun collectPlayerState() {
|
||||||
|
// 'playback', not 'upnp_sync' — player state applies to every output
|
||||||
|
// route (phone speaker, Bluetooth, UPnP), not just casting.
|
||||||
playerController.uiState
|
playerController.uiState
|
||||||
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { (source, upnpLoading, err) ->
|
.collect { (source, upnpLoading, err) ->
|
||||||
record("upnp_sync", buildJsonObject {
|
record("playback", buildJsonObject {
|
||||||
put("event", "player_state")
|
put("event", "player_state")
|
||||||
put("source", source ?: "")
|
put("source", source ?: "")
|
||||||
put("upnp_loading", upnpLoading)
|
put("upnp_loading", upnpLoading)
|
||||||
@@ -160,15 +163,44 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit a snapshot at each track/queue-index change — captures a
|
||||||
|
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
|
||||||
|
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
|
||||||
|
// very rapid skip burst may coalesce intermediate indices (we still get
|
||||||
|
// the boundaries + the local-vs-Sonos snapshot).
|
||||||
|
private suspend fun collectTrackChanges() {
|
||||||
|
playerController.uiState
|
||||||
|
.map { it.queueIndex to it.currentTrack?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (index, _) ->
|
||||||
|
val ui = playerController.uiState.value
|
||||||
|
val casting = outputPicker.routesState.value.current.protocol !=
|
||||||
|
OutputRoute.Protocol.SYSTEM
|
||||||
|
record("playback", buildJsonObject {
|
||||||
|
put("event", "track_change")
|
||||||
|
put("local_index", index)
|
||||||
|
// Track IDENTITY, not just index — indices wobble across
|
||||||
|
// re-casts, so the id + Sonos URI make a desync unambiguous.
|
||||||
|
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||||
|
put("local_pos_ms", ui.positionMs)
|
||||||
|
putSonos(this, casting)
|
||||||
|
put("upnp_loading", ui.isUpnpLoading)
|
||||||
|
put("server_health", networkStatus.state.value.name)
|
||||||
|
put("route", outputPicker.routesState.value.current.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun collectRoutes() {
|
private suspend fun collectRoutes() {
|
||||||
|
// 'playback' — route changes happen for all outputs. This only ever
|
||||||
|
// logs the ACTIVE route (routesState.current), so no "connected" flag.
|
||||||
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
||||||
record("upnp_sync", buildJsonObject {
|
record("playback", buildJsonObject {
|
||||||
put("event", "route")
|
put("event", "route")
|
||||||
put("id", r.id)
|
put("id", r.id)
|
||||||
put("name", r.name)
|
put("name", r.name)
|
||||||
put("kind", r.kind.name)
|
put("kind", r.kind.name)
|
||||||
put("protocol", r.protocol.name)
|
put("protocol", r.protocol.name)
|
||||||
put("connected", r.isConnected)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,13 +218,11 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
put("is_playing", ui.isPlaying)
|
put("is_playing", ui.isPlaying)
|
||||||
put("source", ui.currentSource ?: "")
|
put("source", ui.currentSource ?: "")
|
||||||
put("local_index", ui.queueIndex)
|
put("local_index", ui.queueIndex)
|
||||||
|
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||||
put("local_pos_ms", ui.positionMs)
|
put("local_pos_ms", ui.positionMs)
|
||||||
put("route", route.name)
|
put("route", route.name)
|
||||||
put("route_protocol", route.protocol.name)
|
put("route_protocol", route.protocol.name)
|
||||||
// UPnP/Sonos remote-vs-local — the desync signal.
|
putSonos(this, routeActive)
|
||||||
put("sonos_track", remoteState.trackNumber)
|
|
||||||
put("sonos_pos_ms", remoteState.positionMs)
|
|
||||||
put("sonos_playing", remoteState.isPlaying)
|
|
||||||
addPowerFields(this)
|
addPowerFields(this)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -257,6 +287,18 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
addPowerFields(this)
|
addPowerFields(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
|
||||||
|
// remote route is active; zeroed otherwise so a stale RemotePlayerState
|
||||||
|
// from a just-ended cast can't masquerade as live Sonos data (the
|
||||||
|
// cast→phone handoff artifact). currentTrackUri is the desync ground
|
||||||
|
// truth — it carries the track the speaker is actually streaming.
|
||||||
|
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
|
||||||
|
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
|
||||||
|
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
|
||||||
|
builder.put("sonos_playing", casting && remoteState.isPlaying)
|
||||||
|
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
|
||||||
|
}
|
||||||
|
|
||||||
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
|
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
|
||||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
builder.put("doze", pm.isDeviceIdleMode)
|
builder.put("doze", pm.isDeviceIdleMode)
|
||||||
|
|||||||
+6
-3
@@ -133,7 +133,7 @@ private fun RouteRow(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitle = route.description ?: defaultSubtitle(route)
|
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
// isSelected (route == the active/selected route) drives the Bluetooth
|
||||||
|
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
|
||||||
|
// DISCONNECTED for local SYSTEM routes even when they're in use.
|
||||||
|
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
|
||||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
OutputRoute.Kind.Wired -> "Wired"
|
||||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
|
||||||
OutputRoute.Kind.Cast -> "Cast"
|
OutputRoute.Kind.Cast -> "Cast"
|
||||||
OutputRoute.Kind.Other -> "Available"
|
OutputRoute.Kind.Other -> "Available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
val protocol: Protocol,
|
val protocol: Protocol,
|
||||||
val isConnected: Boolean,
|
|
||||||
) {
|
) {
|
||||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||||
|
|
||||||
@@ -43,9 +42,11 @@ data class OutputRoute(
|
|||||||
/**
|
/**
|
||||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||||
* device types fall through to [Kind.Other]. The
|
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||||
* `connectionState` proxy is good enough for the chip's
|
* intrinsic of the route — the caller derives it by comparing to
|
||||||
* "Connected"/"Available" subtitle.
|
* the selected route (RouteSnapshot.current), because
|
||||||
|
* RouteInfo.connectionState only reflects remote-route handshakes
|
||||||
|
* and stays DISCONNECTED for local SYSTEM routes even when in use.
|
||||||
*/
|
*/
|
||||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||||
val kind = when (route.deviceType) {
|
val kind = when (route.deviceType) {
|
||||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
|||||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||||
else -> Kind.Other
|
else -> Kind.Other
|
||||||
}
|
}
|
||||||
val connected =
|
|
||||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
|
||||||
return OutputRoute(
|
return OutputRoute(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
protocol = Protocol.SYSTEM,
|
protocol = Protocol.SYSTEM,
|
||||||
isConnected = connected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
|||||||
* network speakers into the same `OutputPickerController`
|
* network speakers into the same `OutputPickerController`
|
||||||
* routes stream the system routes come through.
|
* routes stream the system routes come through.
|
||||||
*
|
*
|
||||||
* `isConnected = false` because UPnP devices have no
|
* UPnP devices have no MediaRouter connection-state concept —
|
||||||
* MediaRouter connection-state concept — they're always
|
* they're always "available" on the LAN, and the picker's
|
||||||
* "available" on the LAN, and the picker's selected-route
|
* selected-route rendering handles the "currently playing"
|
||||||
* rendering handles the "currently playing" indicator.
|
* indicator.
|
||||||
*
|
*
|
||||||
* Subtitle is `manufacturer modelName` joined by a single
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
* space, falling back to "Network speaker" when both fields
|
* space, falling back to "Network speaker" when both fields
|
||||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
|||||||
description = description,
|
description = description,
|
||||||
kind = Kind.Other,
|
kind = Kind.Other,
|
||||||
protocol = Protocol.UPNP,
|
protocol = Protocol.UPNP,
|
||||||
isConnected = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import (
|
|||||||
// variants do NOT require a new category here.
|
// variants do NOT require a new category here.
|
||||||
var validDiagnosticKinds = map[string]struct{}{
|
var validDiagnosticKinds = map[string]struct{}{
|
||||||
"connectivity": {},
|
"connectivity": {},
|
||||||
"upnp_sync": {},
|
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
|
||||||
|
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
|
||||||
"power": {},
|
"power": {},
|
||||||
"lifecycle": {},
|
"lifecycle": {},
|
||||||
"heartbeat": {},
|
"heartbeat": {},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Drop rows using the new value first so they don't violate the restored
|
||||||
|
-- (narrower) constraint, then revert the whitelist.
|
||||||
|
DELETE FROM diagnostic_events WHERE kind = 'playback';
|
||||||
|
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||||
|
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http'
|
||||||
|
));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
|
||||||
|
--
|
||||||
|
-- `route` and `player_state` events fire for EVERY output route (phone
|
||||||
|
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
|
||||||
|
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
|
||||||
|
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
|
||||||
|
-- (drops, and future resync/desync); general playback + route telemetry
|
||||||
|
-- moves to the new 'playback' kind.
|
||||||
|
--
|
||||||
|
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
|
||||||
|
-- re-adding the constraint in the same change. Existing rows tagged
|
||||||
|
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
|
||||||
|
|
||||||
|
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||||
|
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http', 'playback'
|
||||||
|
));
|
||||||
@@ -22,11 +22,20 @@
|
|||||||
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
|
||||||
const KINDS = ['connectivity', 'upnp_sync', 'power', 'lifecycle', 'heartbeat', 'http'] as const;
|
const KINDS = [
|
||||||
|
'connectivity',
|
||||||
|
'playback',
|
||||||
|
'upnp_sync',
|
||||||
|
'power',
|
||||||
|
'lifecycle',
|
||||||
|
'heartbeat',
|
||||||
|
'http'
|
||||||
|
] as const;
|
||||||
|
|
||||||
function kindLabel(k: string): string {
|
function kindLabel(k: string): string {
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case 'connectivity': return 'Connectivity';
|
case 'connectivity': return 'Connectivity';
|
||||||
|
case 'playback': return 'Playback';
|
||||||
case 'upnp_sync': return 'UPnP sync';
|
case 'upnp_sync': return 'UPnP sync';
|
||||||
case 'power': return 'Power';
|
case 'power': return 'Power';
|
||||||
case 'lifecycle': return 'Lifecycle';
|
case 'lifecycle': return 'Lifecycle';
|
||||||
@@ -36,13 +45,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default view = the most recent N events, no time window. The
|
||||||
|
// start/end window is an Advanced affordance.
|
||||||
|
const DEFAULT_LIMIT = 500;
|
||||||
|
|
||||||
// Filter state.
|
// Filter state.
|
||||||
let accountId = $state('');
|
let accountId = $state('');
|
||||||
let clientId = $state('');
|
let clientId = $state('');
|
||||||
let kind = $state('');
|
let kind = $state('');
|
||||||
let fromLocal = $state('');
|
let fromLocal = $state('');
|
||||||
let toLocal = $state('');
|
let toLocal = $state('');
|
||||||
let limit = $state(500);
|
let limit = $state(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
// True when an explicit time window is set (drives the caption wording).
|
||||||
|
const hasWindow = $derived(Boolean(fromLocal || toLocal));
|
||||||
|
|
||||||
|
function resetWindow() {
|
||||||
|
fromLocal = '';
|
||||||
|
toLocal = '';
|
||||||
|
limit = DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
|
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
|
||||||
function toRfc(v: string): string | undefined {
|
function toRfc(v: string): string | undefined {
|
||||||
@@ -69,10 +91,17 @@
|
|||||||
const devicesQuery = $derived($devicesStore);
|
const devicesQuery = $derived($devicesStore);
|
||||||
const devices = $derived(devicesQuery.data ?? []);
|
const devices = $derived(devicesQuery.data ?? []);
|
||||||
|
|
||||||
|
// Display sort. The API returns newest-first; default the view to that
|
||||||
|
// (most recent at the top), with an Oldest-first option for reading a
|
||||||
|
// timeline top-to-bottom. Export follows whatever's displayed.
|
||||||
|
let sortOrder = $state<'newest' | 'oldest'>('newest');
|
||||||
|
|
||||||
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
|
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
|
||||||
const diagQuery = $derived($diagStore);
|
const diagQuery = $derived($diagStore);
|
||||||
// API returns newest-first; reverse to chronological for a readable timeline.
|
const rows = $derived.by(() => {
|
||||||
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse());
|
const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
|
||||||
|
return sortOrder === 'oldest' ? data.reverse() : data;
|
||||||
|
});
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -191,77 +220,107 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
|
||||||
<section class="flex flex-wrap items-end gap-3">
|
window; the start/end window lives under Advanced. -->
|
||||||
<label class="block text-xs text-text-secondary">
|
<section class="space-y-3">
|
||||||
Device
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
<select
|
<label class="block text-xs text-text-secondary">
|
||||||
bind:value={clientId}
|
Device
|
||||||
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
<select
|
||||||
>
|
bind:value={clientId}
|
||||||
<option value="">All devices</option>
|
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
{#each devices as d (d.client_id)}
|
>
|
||||||
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
|
<option value="">All devices</option>
|
||||||
{/each}
|
{#each devices as d (d.client_id)}
|
||||||
</select>
|
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
|
||||||
</label>
|
{/each}
|
||||||
<label class="block text-xs text-text-secondary">
|
</select>
|
||||||
Kind
|
</label>
|
||||||
<select
|
<label class="block text-xs text-text-secondary">
|
||||||
bind:value={kind}
|
Kind
|
||||||
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
<select
|
||||||
>
|
bind:value={kind}
|
||||||
<option value="">All kinds</option>
|
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
{#each KINDS as k (k)}
|
>
|
||||||
<option value={k}>{kindLabel(k)}</option>
|
<option value="">All kinds</option>
|
||||||
{/each}
|
{#each KINDS as k (k)}
|
||||||
</select>
|
<option value={k}>{kindLabel(k)}</option>
|
||||||
</label>
|
{/each}
|
||||||
<label class="block text-xs text-text-secondary">
|
</select>
|
||||||
From
|
</label>
|
||||||
<input
|
<label class="block text-xs text-text-secondary">
|
||||||
type="datetime-local"
|
Sort
|
||||||
bind:value={fromLocal}
|
<select
|
||||||
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
bind:value={sortOrder}
|
||||||
/>
|
class="mt-1 block w-36 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
</label>
|
>
|
||||||
<label class="block text-xs text-text-secondary">
|
<option value="newest">Newest first</option>
|
||||||
To
|
<option value="oldest">Oldest first</option>
|
||||||
<input
|
</select>
|
||||||
type="datetime-local"
|
</label>
|
||||||
bind:value={toLocal}
|
|
||||||
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="block text-xs text-text-secondary">
|
|
||||||
Limit
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5000"
|
|
||||||
bind:value={limit}
|
|
||||||
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="ml-auto flex gap-2">
|
<div class="ml-auto flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onCopyJson}
|
onclick={onCopyJson}
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Copy size={15} /> Copy JSON
|
<Copy size={15} /> Copy JSON
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onDownloadNdjson}
|
onclick={onDownloadNdjson}
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Download size={15} /> Download
|
<Download size={15} /> Download
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced: explicit start/end window + row cap. Collapsed by
|
||||||
|
default so the common case (recent N) needs no interaction. -->
|
||||||
|
<details class="rounded-md border border-border" open={hasWindow}>
|
||||||
|
<summary class="cursor-pointer px-3 py-2 text-xs text-text-secondary">
|
||||||
|
Advanced filters{hasWindow ? ' · window active' : ''}
|
||||||
|
</summary>
|
||||||
|
<div class="flex flex-wrap items-end gap-3 border-t border-border px-3 py-3">
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={fromLocal}
|
||||||
|
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={toLocal}
|
||||||
|
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
Limit
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5000"
|
||||||
|
bind:value={limit}
|
||||||
|
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetWindow}
|
||||||
|
class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
Reset to recent {DEFAULT_LIMIT}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
@@ -276,7 +335,13 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-text-muted">{rows.length} events (oldest first)</span>
|
<span class="text-xs text-text-muted">
|
||||||
|
{#if hasWindow}
|
||||||
|
{rows.length} events in window ({sortOrder === 'newest' ? 'newest' : 'oldest'} first)
|
||||||
|
{:else}
|
||||||
|
Most recent {rows.length} events ({sortOrder === 'newest' ? 'newest' : 'oldest'} first) · set a time window under Advanced
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
|
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
|
||||||
{#each rows as r (r.id)}
|
{#each rows as r (r.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user