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:
Hugh Kaznowski
2026-09-06 01:58:30 +01:00
co-authored by Claude Sonnet 5
parent 70d98562d1
commit 3d2652e95b
7 changed files with 381 additions and 20 deletions
+97 -2
View File
@@ -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">