Files
windmill/frontend/src/lib/components/ServiceLogsInner.svelte
Diego Imbert cebb47ea20 Refactor runs page search params state logic (#7543)
* start refactoring runs page query params state

* more refactor

* more migration

* more refactor

* per_page migrate

* refactor

* char consistency layout

* runs filter wrong logic

* fix autosetFilter

* nit remove Default Email filter

* Revert "nit remove Default Email filter"

This reverts commit b46b3872a9.

* nit

* arg nits

* fix filters.arg reactivity

* RunsFilter pass 1

* Refactor JobsLoader into useJobsLoader

* fix lastFetchWentToEnd

* update claude.md

* debounce to avoid flicker

* debounce

* callback mistake

* missing return

* change to null

* dead code file (SavedInputs was replaced with V2)

* arg and result filter nits

* better flicker prevention

* avoid duplicates when loading more jobs

* fix broken type

* nit type

* improve date filter state mgmt + encode URI component on all params
2026-01-15 07:20:04 +00:00

853 lines
25 KiB
Svelte

<script lang="ts">
import { createBubbler, preventDefault } from 'svelte/legacy'
const bubble = createBubbler()
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
import LogViewer from './LogViewer.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
import { Button, Drawer, DrawerContent } from './common'
import ClipboardCopy from 'lucide-svelte/icons/clipboard-copy'
import { AnsiUp } from 'ansi_up'
import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte'
import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
interface Props {
searchTerm: string
queryParseErrors?: string[]
tagLabel?: string
}
let { searchTerm, queryParseErrors = $bindable(), tagLabel }: Props = $props()
let minTs: undefined | string = $state(undefined)
let maxTs: undefined | string = $state(undefined)
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
let max_lines: undefined | number = $state(undefined)
// let lastSeen: undefined | string = undefined
let withError = $state(false)
let autoRefresh = $state(true)
let loading = $state(false)
type LogFile = {
ts: number
file_path: string
ok_lines: number
err_lines: number
json_fmt: boolean
}
type ByHostname = Record<string, LogFile[]>
type ByWorkerGroup = Record<string, ByHostname>
type ByMode = Record<string, ByWorkerGroup>
let timeout: number | undefined = $state(undefined)
let allLogs: ByMode | undefined = $state(undefined)
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
let upTo: undefined | string = $state(undefined)
let upToIsLatest = $state(true)
function onManualChanges() {
getAllLogs(minTsManual ?? maxTs, maxTsManual)
}
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
timeout && clearTimeout(timeout)
loading = true
allLogs = allLogs ?? {}
ServiceLogsService.listLogFiles({ withError, before: queryMaxTs, after: queryMinTs })
.then((res) => {
loading = false
let minTsN: number | undefined = undefined
let maxTsN: number | undefined = undefined
if (minTsManual) {
minTsN = new Date(minTsManual).getTime()
Object.values(allLogs ?? {}).forEach((mode) => {
Object.values(mode).forEach((wg) => {
Object.keys(wg).forEach((key) => {
wg[key] = wg[key].filter(
(x) => !minTsManual || x.ts >= new Date(minTsManual).getTime()
)
})
})
})
}
res.reverse().forEach((log) => {
let ts = new Date(log.log_ts + 'Z').getTime()
if (minTsN == undefined || ts < minTsN) {
minTsN = ts
}
if (maxTsN == undefined || ts > maxTsN) {
maxTsN = ts
}
if (allLogs == undefined) {
allLogs = {}
}
if (!allLogs[log.mode]) {
allLogs[log.mode] = {}
}
const wg = log.worker_group ?? ''
if (!allLogs[log.mode][wg]) {
allLogs[log.mode][wg] = {}
}
const hn = log.hostname ?? ''
if (!allLogs[log.mode][wg][hn]) {
allLogs[log.mode][wg][hn] = []
}
allLogs[log.mode][wg][hn].push({
ts: ts,
file_path: log.file_path,
ok_lines: log.ok_lines ?? 1,
err_lines: log.err_lines ?? 0,
json_fmt: log.json_fmt
})
if (
log.ok_lines != undefined &&
log.err_lines != undefined &&
(max_lines == undefined || log.ok_lines + log.err_lines > max_lines)
) {
max_lines = log.ok_lines + log.err_lines
}
})
Object.values(allLogs ?? {}).forEach((mode) => {
Object.values(mode).forEach((wg) => {
Object.keys(wg).forEach((key) => {
wg[key] = wg[key].filter(
(x) => !minTsManual || x.ts >= new Date(minTsManual).getTime()
)
})
})
})
loading = false
if (minTs == undefined) {
minTs = minTsN ? new Date(minTsN).toISOString() : undefined
}
if (maxTsN) {
maxTs = new Date(maxTsN).toISOString()
}
if (upToIsLatest && selected) {
upTo = getLatestUpTo(selected)
}
if (autoRefresh && searchTerm === '' && !maxTsManual) {
timeout = setTimeout(() => {
if (searchTerm !== '') return
let minMax = manualPicker?.computeMinMax()
if (minMax) {
maxTsManual = minMax?.maxTs ?? undefined
minTsManual = minMax?.minTs ?? undefined
}
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
getAllLogs(maxTsPlus1?.toISOString(), undefined)
}, 5000)
}
})
.catch((e) => {
sendUserToast('Failed to load service logs: ' + e.body, true)
console.error(e)
loading = false
autoRefresh = false
})
}
type Selected = { mode: string; workerGroup: string; hostname: string }
let initialSelected =
$page.url.searchParams.get('mode') &&
$page.url.searchParams.get('workerGroup') &&
$page.url.searchParams.get('hostname')
? {
mode: $page.url.searchParams.get('mode')!,
workerGroup: $page.url.searchParams.get('workerGroup')!,
hostname: $page.url.searchParams.get('hostname')!
}
: undefined
let selected: Selected | undefined = $state(initialSelected)
let logsContent: Record<string, { content?: string; error?: string }> = $state({})
export async function getLogFile(hostname: string, path: string) {
if (logsContent[path]) {
return
}
try {
const res = await ServiceLogsService.getLogFile({ path: `${hostname}/${path}` })
logsContent[path] = { content: res }
} catch (e) {
logsContent[path] = { error: `${e.message}: ${e.body}` }
}
}
getAllLogs(undefined, undefined)
function getLogs(selected: Selected, upTo: string | undefined) {
if (!selected) {
return []
}
let logs = allLogs?.[selected.mode]?.[selected.workerGroup]?.[selected.hostname]
if (!logs) {
return []
}
if (upTo) {
let upToN = new Date(upTo).getTime()
let nlogs = logs.filter((x) => x.ts <= upToN)
logs = nlogs.slice(nlogs.length - 5, undefined)
getFiles(
selected.hostname,
logs.map((x) => x.file_path)
)
}
return logs
}
async function getFiles(hostname: string, logs: string[]) {
await Promise.all(logs.map((x) => getLogFile(hostname, x)))
scrollToBottom()
}
function getLatestUpTo(selected: Selected): any {
if (!selected) {
return undefined
}
let logs = allLogs?.[selected.mode]?.[selected.workerGroup]?.[selected.hostname]
if (!logs) {
return undefined
}
return logs[logs.length - 1]?.ts
}
function scrollToBottom() {
const el = document.querySelector('#logviewer')
if (el) {
el.scrollTop = el.scrollHeight
}
}
onDestroy(() => {
timeout && clearTimeout(timeout)
})
function processLogWithJsonFmt(log: string | undefined, jsonFmt: boolean): string {
if (!log) {
return ''
}
if (!jsonFmt) {
return log
}
try {
let res = ''
log.split('\n').forEach((line) => {
if (line.startsWith('{') && line.endsWith('}')) {
let obj = JSON.parse(line)
if (typeof obj == 'object') {
let nl = ''
if (obj['timestamp']) {
nl += obj['timestamp'] + ' '
}
if (obj['level']) {
let lvl = obj['level']
if (lvl == 'ERROR') {
nl += '\x1b[31mERROR\x1b[0m '
} else if (lvl == 'INFO') {
nl += '\x1b[32mINFO\x1b[0m '
} else {
nl += obj['level'] + ' '
}
}
if (obj['message']) {
nl += obj['message'] + ' '
}
delete obj['timestamp']
delete obj['level']
delete obj['message']
Object.keys(obj).forEach((key) => {
nl +=
key +
'=' +
(typeof obj[key] == 'object' ? JSON.stringify(obj[key]) : obj[key]) +
' '
})
res += nl + '\n'
}
}
})
return res
} catch (e) {
return log
}
}
let logs: any = $state()
let debounceTimeout: number | undefined = undefined
const debouncePeriod: number = 400
let loadingLogs = $state(false)
let loadingLogCounts = $state(false)
let countsPerHost: any = $state()
let sumOtherDocCount: number = $state(0)
async function searchLogs(
searchTerm: string,
selected: Selected | undefined,
minTs: string | undefined,
maxTs: string | undefined,
allLogs: ByMode | undefined
) {
const params = new URLSearchParams()
if (searchTerm) params.set('searchTerm', searchTerm)
if (minTs) params.set('minTs', minTs)
if (maxTs) params.set('maxTs', maxTs)
if (selected?.mode) params.set('mode', selected.mode)
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
if (selected?.hostname) params.set('hostname', selected.hostname)
goto(`?${params.toString()}`)
if (searchTerm.trim() === '') {
debounceTimeout && clearTimeout(debounceTimeout)
logs = undefined
countsPerHost = undefined
sumOtherDocCount = 0
loadingLogs = false
loadingLogCounts = false
return
}
timeout && clearTimeout(timeout)
loadingLogCounts = true
loadingLogs = true
debounceTimeout && clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(async () => {
if (allLogs) {
const countLogsResponse = await IndexSearchService.countSearchLogsIndex({
searchQuery: searchTerm,
minTs,
maxTs
})
const res = (countLogsResponse.count_per_host as any)['count_per_host']
const buckets = res['buckets']
sumOtherDocCount = res['sum_other_doc_count']
countsPerHost = new Map(buckets.map(({ key, doc_count }) => [key, doc_count]))
countsPerHost = buckets.reduce(
(acc: any, { key, doc_count }) => {
acc[key] = { doc_count }
return acc
},
{} as Record<string, number>
)
queryParseErrors = countLogsResponse.query_parse_errors ?? []
loadingLogCounts = false
}
if (selected) {
logs = await IndexSearchService.searchLogsIndex({
searchQuery: searchTerm,
mode: selected.mode,
workerGroup: selected.workerGroup != '' ? selected.workerGroup : undefined,
hostname: selected.hostname,
minTs,
maxTs
})
}
loadingLogs = false
}, debouncePeriod)
}
const ansi_up = new AnsiUp()
ansi_up.use_classes = true
let logDrawer: Drawer | undefined = $state(undefined)
let logDrawerOpen: boolean = $state(false)
let content: string = $state('')
let hitLineNumber: number | undefined = $state(undefined)
async function seeLogContext(
lineNumber: number,
path: string,
hostname: string,
jsonFmt: boolean
) {
const res = await ServiceLogsService.getLogFile({ path: `${hostname}/${path}` })
content = processLogWithJsonFmt(ansi_up.ansi_to_html(res), jsonFmt)
hitLineNumber = lineNumber
logDrawerOpen = true
await tick()
let el = document.getElementById(`log-line-${lineNumber}`)
if (el) scroll_into_view_if_needed_polyfill(el, false)
}
function allLogsOrQueryResults(allLogs: ByMode, countsPerHost: any): ByMode {
if (countsPerHost == undefined) {
return allLogs
}
let ret = {}
for (const hk of Object.keys(countsPerHost)) {
let u = hk.split(',')
let [mode, wg, hn] = [u[0], u[1], u[2]]
if (!ret[mode]) {
ret[mode] = {}
}
if (!ret[mode][wg]) {
ret[mode][wg] = {}
}
if (!ret[mode][wg][hn]) {
ret[mode][wg][hn] = []
}
}
return ret
}
function getSelectItems(
allLogs: ByMode,
countsPerHost: any
): { label: string; value: Selected }[] {
return Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)).flatMap(([mode, o1]) =>
Object.entries(o1).flatMap(([wg, o2]) =>
Object.keys(o2).map((hn) => ({
label: hn,
value: { mode, workerGroup: wg, hostname: hn }
}))
)
)
}
$effect(() => {
minTsManual || maxTsManual || untrack(() => onManualChanges())
})
$effect(() => {
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
})
</script>
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
<DrawerContent title="See context" on:close={logDrawer.closeDrawer}>
{#snippet actions()}
<Button
on:click={() => copyToClipboard(content)}
color="light"
size="xs"
startIcon={{
icon: ClipboardCopy
}}
>
Copy to clipboard
</Button>
{/snippet}
<div class="w-fit">
<pre
class="bg-surface-secondary text-secondary text-xs w-full p-2 whitespace-pre border rounded-md"
>...<br />{#each content.split('\n') as line, index}<div
id={`log-line-${index}`}
class={index === hitLineNumber ? 'bg-yellow-200 bg-opacity-20' : ''}>{@html line}</div
>{/each}...</pre
>
</div>
</DrawerContent>
</Drawer>
<SplitPanesOrColumnOnMobile>
{#snippet left_pane()}
<div class="p-1">
<div
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
id="service-logs-date-pickers"
>
<div class="flex relative">
<input
type="text"
value={minTsManual
? new Date(minTsManual).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: 'min datetime'}
disabled
/>
<CalendarPicker
label="min datetime"
date={minTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
minTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
placement="top-start"
/>
</div>
<ManuelDatePicker
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
bind:this={manualPicker}
{loading}
on:loadJobs={() => {
minTs = undefined
maxTs = undefined
allLogs = undefined
getAllLogs(minTsManual, maxTsManual)
}}
serviceLogsChoices
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
/>
<div class="flex relative">
<input
type="text"
value={maxTsManual
? new Date(maxTsManual).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: 'max datetime'}
disabled
/>
<CalendarPicker
label="max datetime"
date={maxTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
maxTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
/>
</div>
</div>
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
><Toggle
size="xs"
bind:checked={withError}
options={{ right: 'errors > 0' }}
on:change={() => {
allLogs = undefined
getAllLogs(minTs, maxTs)
}}
/>
<Toggle
size="xs"
bind:checked={autoRefresh}
disabled={searchTerm != ''}
on:change={(e) => {
if (e.detail) {
getAllLogs(maxTs, undefined)
} else {
timeout && clearTimeout(timeout)
}
}}
options={{ right: 'auto-refresh' }}
/></div
>
{#if allLogs == undefined}
<div class="text-center pb-2"><Loader2 class="animate-spin" /></div>
{:else if Object.keys(allLogs).length == 0}
<div class="flex justify-center items-center h-full">No logs</div>
{:else if minTs && maxTs}
{@const minTsN = new Date(minTs).getTime()}
{@const maxTsN = new Date(maxTs).getTime()}
{@const diff = maxTsN - minTsN}
{#if searchTerm === ''}
<div class="flex w-full text-2xs text-primary pb-6">
<div style="width: 60px;"></div>
<div class="flex justify-between w-full"
><div
>{new Date(minTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
><div
>{new Date(maxTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
></div
>
</div>
{/if}
<div class="mr-0.5 mb-2">
<Select
bind:value={selected}
items={getSelectItems(allLogs, countsPerHost)}
onClear={() => {
selected = undefined
}}
placeholder="Select a service"
/>
</div>
{#each Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]}
<div class="w-full pb-8">
<h2 class="pb-2 text-2xl">{mode}s</h2>
{#each Object.entries(o1) as [wg, o2]}
<div class="w-full px-1">
{#if wg && wg != ''}
<h4 class="pt-4">{wg}</h4>
{/if}
<div class="divide-y flex flex-col">
{#each Object.entries(o2).filter(([hn, files]) => {
if (selected && selected.mode === mode && selected.workerGroup === wg && selected.hostname === hn) {
return true
}
const hostKey = `${mode},${wg},${hn}`
if (countsPerHost && (countsPerHost[hostKey] == undefined || countsPerHost[hostKey].doc_count === 0)) {
return false
}
return true
}) as [hn, files]}
{@const hostKey = `${mode},${wg},${hn}`}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex items-baseline justify-between rounded px-1 hover:bg-surface-hover cursor-pointer {selected &&
selected.mode == mode &&
selected.workerGroup == wg &&
selected.hostname == hn
? 'bg-surface-secondary'
: ''}"
onclick={() => {
selected = { mode, workerGroup: wg, hostname: hn }
upToIsLatest = true
upTo = getLatestUpTo(selected)
scrollToBottom()
}}
>
<div
class="text-sm pt-2 pl-0.5 whitespace-nowrap"
title={hn}
style="width: 90px;"
>{truncateRev(hn, countsPerHost || loadingLogs ? 40 : 8)}</div
>
{#if loadingLogCounts}
<Loader2 size={15} class="animate-spin" />
{:else if countsPerHost}
<div class="text-primary text-xs">
{countsPerHost[hostKey]?.doc_count ?? 0} matches
</div>
{:else}
<div class="relative grow h-8 mr-2">
{#each files as file}
{@const okHeight = 100.0 * ((file.ok_lines * 1.0) / (max_lines ?? 1))}
{@const errHeight = 100.0 * ((file.err_lines * 1.0) / (max_lines ?? 1))}
<div
class=" w-2 bg-red-400 absolute"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {errHeight}%; bottom: {okHeight}%;"
></div>
<div
class="w-2 bg-surface-secondary-inverse absolute bottom-0"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {okHeight}%"
></div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/each}
{#if !loadingLogCounts && sumOtherDocCount != 0}
<div class="text-primary italic text-sm">
Note: {sumOtherDocCount} additional matches weren't grouped into any of the above hosts.
</div>
{/if}
{/if}
</div>
{/snippet}
{#snippet right_pane()}
<div class="relative h-full flex flex-col gap-1 pb-2">
{#if selected}
{#if !loadingLogs && logs == undefined}
<div class="w-full bg-surface-primary-inverse text-primary text-xs text-center">
1 min delay: logs are compacted before being available
</div>
{/if}
<div class="grow overflow-auto" id="logviewer">
{#if loadingLogs}
<div class="flex w-full justify-center items-center h-48">
<div class="text-primary text-center">
<Loader2 size={34} class="animate-spin" />
</div>
</div>
{:else if logs != undefined}
<div class="flex flex-col min-w-full w-fit">
{#each logs.hits as { snippet_fragment, snippet_highlighted, document }}
<LogSnippetViewer
content={snippet_fragment || document.logs[0]}
highlighted={snippet_highlighted}
on:click={() => {
let logLineNumber = document.line_number[0]
let logFile = document.file_name[0]
let host = document.host[0]
let jsonFmt = document.json_fmt[0]
seeLogContext(logLineNumber, logFile, host, jsonFmt)
}}
/>
{/each}
{#if logs.hits.length === 0}
<div class="text-center py-20 text-bold text-xl text-primary"> No logs </div>
{/if}
{#if logs.hits.length === 1000}
<div class="pl-6 py-6 text-sm text-secondary">
Older matches were truncated from this search, try refining your filters to get
more precise results.
</div>
{/if}
<div class="py-20"></div>
</div>
{:else}
{#each getLogs(selected, upTo) as file}
<div
style="min-height: {logsContent[file.file_path]
? 10
: Math.min(file.ok_lines + file.err_lines, 30) * 16}px;"
>
<div class="bg-surface-primary-inverse text-sm font-semibold px-1"
>{new Date(file.ts).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
>
{#if logsContent[file.file_path] == undefined}
<div
class="animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms] h-full w-full"
></div>
{:else if logsContent[file.file_path]}
{#if logsContent[file.file_path].error}
{#if logsContent[file.file_path].error?.startsWith('Not Found')}
<div class="text-xs pb-4 pt-2 text-secondary"
>Log file is missing. Log files require a shared log volume to be mounted
across servers and workers or to use the EE S3/object storage integration
for logs. To avoid mounting a shared volume, set the EE object store logs in
the instance settings</div
>
{:else}
<div class="text-xs text-red-400 pb-4"
>{logsContent[file.file_path].error}</div
>
{/if}
{:else if logsContent[file.file_path].content}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={preventDefault(bubble('click'))} class="pr-2"
><LogViewer
noAutoScroll
noMaxH
isLoading={false}
tag={undefined}
{tagLabel}
content={processLogWithJsonFmt(
logsContent[file.file_path].content,
file.json_fmt
)}
/></div
>
{:else}
<div>No logs</div>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
{#if searchTerm == ''}
<div class="flex w-full items-center gap-4">
<div class="text-primary px-1 text-2xs">Last 5 log files up to:</div>
<div class="flex grow text-xs justify-center px-2 items-center gap-2">
{#if upTo}
<button
onclick={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() - 5 * 60 * 1000).toISOString()
}
}}>{'<'} 5m</button
>
{:else}
<div></div>
{/if}
<div class="flex gap-1 relative items-center"
><div class="flex gap-1 relative">
<input
type="text"
value={upTo
? new Date(upTo).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: ''}
disabled
/><CalendarPicker bind:date={upTo} label="Logs up to" /></div
></div
>
{#if upTo}
<button
onclick={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() + 5 * 60 * 1000).toISOString()
}
}}>5m {'>'}</button
>
{:else}
<div></div>
{/if}
</div>
<div>
<button
class="text-xs"
onclick={() => {
upTo = new Date().toISOString()
upToIsLatest = true
}}>now</button
>
</div>
</div>
{/if}
{:else}
<div class="flex justify-center items-center pt-8">Select a host to see its logs</div>
{/if}</div
>
{/snippet}
</SplitPanesOrColumnOnMobile>