Add sort_by ordering for the log viewer, with a missing-param fallback/drop toggle (ticket 045)
/api/logs now accepts sort_by (an already-declared param name) and sort_missing_fallback: lines carrying that param are ordered by its value, while lines missing it are either appended in ottyl_seq order (fallback, the default) or dropped from the response. Default ottyl_ts-tracking order is unchanged when sort_by is omitted. The log viewer (both the inline panel and its popout) exposes this as a "Sort by" control next to the column picker, persisted to localStorage under one shared key (ottyl.logPreview.sort) — the same global-viewer- setting convention already used for visible param columns and the log viewer's height, since there's no per-dashboard scope for this to divide by. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
70d98562d1
commit
3d2652e95b
+33
-7
@@ -9,7 +9,7 @@ import {
|
||||
type TimeRange,
|
||||
type TimeRangePresetId,
|
||||
} from './components/LogTimeline'
|
||||
import { LogPreview } from './components/LogPreview'
|
||||
import { LogPreview, loadLogSortSettings, saveLogSortSettings, type LogSortSettings } from './components/LogPreview'
|
||||
import { Params } from './components/Params'
|
||||
import { Metrics } from './components/Metrics'
|
||||
import { Dashboards } from './components/Dashboards'
|
||||
@@ -260,6 +260,12 @@ function StandaloneLog() {
|
||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||
|
||||
function handleSortSettingsChange(next: LogSortSettings) {
|
||||
setSortSettings(next)
|
||||
saveLogSortSettings(next)
|
||||
}
|
||||
|
||||
const rangeRef = useRef(range)
|
||||
useEffect(() => {
|
||||
@@ -310,7 +316,14 @@ function StandaloneLog() {
|
||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||
filter: filter || undefined,
|
||||
}),
|
||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
||||
fetchLogs({
|
||||
startMs: effectiveRange?.startMs,
|
||||
endMs: effectiveRange?.endMs,
|
||||
limit: 1000,
|
||||
filter: filter || undefined,
|
||||
sortBy: sortSettings.sortBy ?? undefined,
|
||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||
}),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setBuckets(nextBuckets)
|
||||
@@ -331,7 +344,7 @@ function StandaloneLog() {
|
||||
clearInterval(id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter, activePreset])
|
||||
}, [filter, activePreset, sortSettings])
|
||||
|
||||
return (
|
||||
<StandalonePage>
|
||||
@@ -345,7 +358,7 @@ function StandaloneLog() {
|
||||
activePreset={activePreset}
|
||||
onSelectPreset={selectPreset}
|
||||
/>
|
||||
<LogPreview logs={logs} params={params} />
|
||||
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
||||
</div>
|
||||
</div>
|
||||
</StandalonePage>
|
||||
@@ -373,6 +386,12 @@ function FullApp() {
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||
|
||||
function handleSortSettingsChange(next: LogSortSettings) {
|
||||
setSortSettings(next)
|
||||
saveLogSortSettings(next)
|
||||
}
|
||||
|
||||
function handleLogViewerResize(deltaY: number) {
|
||||
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
|
||||
@@ -442,7 +461,14 @@ function FullApp() {
|
||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||
filter: filter || undefined,
|
||||
}),
|
||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
||||
fetchLogs({
|
||||
startMs: effectiveRange?.startMs,
|
||||
endMs: effectiveRange?.endMs,
|
||||
limit: 1000,
|
||||
filter: filter || undefined,
|
||||
sortBy: sortSettings.sortBy ?? undefined,
|
||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||
}),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setStats(nextStats)
|
||||
@@ -465,7 +491,7 @@ function FullApp() {
|
||||
}
|
||||
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter, activePreset])
|
||||
}, [filter, activePreset, sortSettings])
|
||||
|
||||
return (
|
||||
<DocsNavProvider>
|
||||
@@ -498,7 +524,7 @@ function FullApp() {
|
||||
activePreset={activePreset}
|
||||
onSelectPreset={selectPreset}
|
||||
/>
|
||||
<LogPreview logs={logs} params={params} />
|
||||
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
||||
</div>
|
||||
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
||||
<Params params={params} onDeclared={loadParams} />
|
||||
|
||||
@@ -25,6 +25,87 @@ function saveVisibleParamColumns(columns: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ticket 045: ordering key + missing-param handling for the log preview / timeline. Scoped like
|
||||
// `VISIBLE_PARAM_COLUMNS_STORAGE_KEY` and `App.tsx`'s `LOG_VIEWER_HEIGHT_STORAGE_KEY` above — one
|
||||
// shared "log viewer" concept, so a single localStorage key covers both the inline viewer
|
||||
// (`FullApp`) and its popout (`StandaloneLog`) rather than each keeping its own copy, which would
|
||||
// surprise a user switching between the two. There's no dashboard/panel-scoped analog for this
|
||||
// setting to divide by (unlike e.g. per-dashboard panels), so a single global key matches how the
|
||||
// rest of the log viewer's own settings are scoped.
|
||||
const LOG_SORT_STORAGE_KEY = 'ottyl.logPreview.sort'
|
||||
|
||||
export interface LogSortSettings {
|
||||
/** An already-declared param name to order log lines by, or `null` for the default (`ottyl_ts`-tracking) order. */
|
||||
sortBy: string | null
|
||||
/** Only meaningful when `sortBy` is set. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them from view. */
|
||||
sortMissingFallback: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LOG_SORT_SETTINGS: LogSortSettings = { sortBy: null, sortMissingFallback: true }
|
||||
|
||||
export function loadLogSortSettings(): LogSortSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOG_SORT_STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_LOG_SORT_SETTINGS
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return DEFAULT_LOG_SORT_SETTINGS
|
||||
const sortBy = typeof parsed.sortBy === 'string' ? parsed.sortBy : null
|
||||
const sortMissingFallback = typeof parsed.sortMissingFallback === 'boolean' ? parsed.sortMissingFallback : true
|
||||
return { sortBy, sortMissingFallback }
|
||||
} catch {
|
||||
return DEFAULT_LOG_SORT_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLogSortSettings(settings: LogSortSettings) {
|
||||
try {
|
||||
localStorage.setItem(LOG_SORT_STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
// best-effort — private browsing or full storage just means the selection won't persist across reloads
|
||||
}
|
||||
}
|
||||
|
||||
function SortPicker({
|
||||
paramNames,
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
paramNames: string[]
|
||||
settings: LogSortSettings
|
||||
onChange: (next: LogSortSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<label className="flex items-center gap-1">
|
||||
Sort by
|
||||
<select
|
||||
value={settings.sortBy ?? ''}
|
||||
onChange={(e) => onChange({ ...settings, sortBy: e.target.value || null })}
|
||||
className="rounded border border-border-soft bg-surface-0 px-1 py-0.5 text-text"
|
||||
>
|
||||
<option value="">ottyl_ts (default)</option>
|
||||
{paramNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{settings.sortBy && (
|
||||
<label className="flex cursor-pointer items-center gap-1" title="How lines missing the sort param are handled">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sortMissingFallback}
|
||||
onChange={(e) => onChange({ ...settings, sortMissingFallback: e.target.checked })}
|
||||
className="accent-text-muted"
|
||||
/>
|
||||
Fall back to ottyl_seq
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColumnPicker({
|
||||
paramNames,
|
||||
visible,
|
||||
@@ -209,7 +290,18 @@ function useScrollAnchor(containerRef: React.RefObject<HTMLDivElement | null>, d
|
||||
}, deps)
|
||||
}
|
||||
|
||||
export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
|
||||
export function LogPreview({
|
||||
logs,
|
||||
params,
|
||||
sortSettings,
|
||||
onSortSettingsChange,
|
||||
}: {
|
||||
logs: LogEntry[]
|
||||
params: ParamDeclaration[]
|
||||
/** Ticket 045: current ordering choice, owned by the caller (it also feeds `fetchLogs`) rather than internally like `visibleParamColumns` — see `App.tsx`'s poll loop. */
|
||||
sortSettings: LogSortSettings
|
||||
onSortSettingsChange: (next: LogSortSettings) => void
|
||||
}) {
|
||||
const paramNames = params.map((d) => d.name)
|
||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -237,7 +329,10 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
||||
Log preview
|
||||
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
||||
</div>
|
||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||
<div className="flex items-center gap-3">
|
||||
<SortPicker paramNames={paramNames} settings={sortSettings} onChange={onSortSettingsChange} />
|
||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
|
||||
@@ -244,6 +244,10 @@ export function fetchLogs(params: {
|
||||
endMs?: number
|
||||
limit?: number
|
||||
filter?: string
|
||||
/** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */
|
||||
sortBy?: string
|
||||
/** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */
|
||||
sortMissingFallback?: boolean
|
||||
}): Promise<LogEntry[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||
@@ -251,6 +255,8 @@ export function fetchLogs(params: {
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||
if (params.filter) search.set('filter', params.filter)
|
||||
if (params.sortBy) search.set('sort_by', params.sortBy)
|
||||
if (params.sortMissingFallback !== undefined) search.set('sort_missing_fallback', String(params.sortMissingFallback))
|
||||
return getJson(`/api/logs?${search.toString()}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user