Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useMemo, useEffect, useCallback, useRef, useContext } from 'react'
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
import { useNow } from '../../hooks/useNow'
import { PaneLoader } from '../ui/PaneLoader'
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
import {
Expand Down Expand Up @@ -1749,6 +1750,26 @@ function getInitialFiltersFromURL() {
// Sort state type
type SortDirection = 'asc' | 'desc' | null

/**
* "Updated Xs" / "Updated 1m" badge in the toolbar.
*
* Lives in its own component so the 1Hz `useNow` tick re-renders
* only this tiny label, NOT the entire ResourcesView (which is
* ~4000 lines and contains a virtualized table). Without this
* boundary, every visible row's React render would run once per
* second just to advance one label by 1s.
*/
function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
// Read the ticking clock here so only this subtree re-renders.
useNow(1000)
return (
<div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
<Clock className="w-3.5 h-3.5" />
<span>Updated {formatAge(lastUpdated.toISOString())}</span>
</div>
)
}

export function ResourcesView({
namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
apiResources: apiResourcesProp,
Expand Down Expand Up @@ -2624,12 +2645,25 @@ export function ResourcesView({

const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(() => refetchFn?.())

// Track last updated time
// Track last updated time.
//
// React Query bumps `dataUpdatedAt` every time it records a successful
// fetch — even when the response is byte-identical to what's already
// cached and structural sharing returns the same `data` reference.
// Mounting / focusing windows / a sibling subscriber issuing the same
// queryKey can all trigger a no-op refetch. Resetting the user-visible
// "Updated Xs" timer on those events is misleading: it suggests fresh
// data arrived when nothing actually changed, and the user reads the
// "<1s" jump as evidence that opening a filter drawer triggered a real
// network round-trip. Gate the bump on data-reference change so we only
// bump when the cache actually mutated.
const lastDataRef = useRef<unknown>(undefined)
useEffect(() => {
if (dataUpdatedAt) {
if (dataUpdatedAt && resources !== lastDataRef.current) {
lastDataRef.current = resources
setLastUpdated(new Date(dataUpdatedAt))
}
}, [dataUpdatedAt])
}, [dataUpdatedAt, resources])

// Derive counts — prefer lightweight resourceCounts prop over full query data
const counts = useMemo(() => {
Expand Down Expand Up @@ -3468,12 +3502,7 @@ export function ResourcesView({
</span>
)}

{lastUpdated && (
<div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
<Clock className="w-3.5 h-3.5" />
<span>Updated {formatAge(lastUpdated.toISOString())}</span>
</div>
)}
{lastUpdated && <LastUpdatedLabel lastUpdated={lastUpdated} />}
{/* Column picker */}
<div className="relative" ref={columnPickerRef}>
<button
Expand Down Expand Up @@ -3953,7 +3982,19 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
)
}
if (column === 'age') {
return <span className="text-sm text-theme-text-secondary">{formatAge(meta.creationTimestamp)}</span>
// Tooltip with the absolute creationTimestamp gives users a
// stable reference: relative-age values felt like they shifted
// between refreshes, eroding trust in the column. The absolute
// timestamp is the ground truth — exposing it on hover lets
// users self-verify without leaving the table.
if (!meta.creationTimestamp) {
return <span className="text-sm text-theme-text-secondary">-</span>
}
return (
<Tooltip content={new Date(meta.creationTimestamp).toLocaleString()}>
<span className="text-sm text-theme-text-secondary">{formatAge(meta.creationTimestamp)}</span>
</Tooltip>
)
}

// Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
Expand Down
1 change: 1 addition & 0 deletions packages/k8s-ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { useAnimatedUnmount } from './useAnimatedUnmount'
export { useRefreshAnimation } from './useRefreshAnimation'
export { useNow } from './useNow'
export {
KeyboardShortcutProvider,
useRegisterShortcut,
Expand Down
108 changes: 108 additions & 0 deletions packages/k8s-ui/src/hooks/useNow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest'
import { useNow, shouldScheduleNow, scheduleNowTicks } from './useNow'

// This package's vitest config has no @testing-library/react and no
// jsdom — we can't render hooks here. Instead the hook's effect
// body is hoisted into the pure `scheduleNowTicks` driver, and we
// exercise THAT directly: the same opt-out rules, the same
// setInterval call, the same cleanup contract that the hook
// installs at runtime. The hook itself is also imported so the
// suite fails if its export shape regresses.

describe('useNow', () => {
it('exports a callable hook with the documented signature', () => {
expect(typeof useNow).toBe('function')
expect(useNow.length).toBeLessThanOrEqual(1)
})
})

describe('shouldScheduleNow', () => {
it('schedules when intervalMs is a positive number', () => {
expect(shouldScheduleNow(1)).toBe(true)
expect(shouldScheduleNow(1000)).toBe(true)
expect(shouldScheduleNow(60_000)).toBe(true)
})

it('opts out when intervalMs is null', () => {
expect(shouldScheduleNow(null)).toBe(false)
})

it('opts out when intervalMs is zero', () => {
expect(shouldScheduleNow(0)).toBe(false)
})

it('opts out when intervalMs is negative', () => {
expect(shouldScheduleNow(-1)).toBe(false)
expect(shouldScheduleNow(-1000)).toBe(false)
})
})

describe('scheduleNowTicks (the pure body of useNow)', () => {
function fakeTimers() {
// Type the spies explicitly so TS knows .mock.calls has the
// (cb, ms) shape — vi.fn() inference defaults to a 0-arity
// signature otherwise.
const setInterval = vi.fn<(cb: () => void, ms: number) => unknown>(() => 'TIMER_ID' as unknown)
const clearInterval = vi.fn<(id: unknown) => void>()
const now = vi.fn<() => number>(() => 1700000000000)
return {
timers: { setInterval, clearInterval, now },
setInterval,
clearInterval,
now,
}
}

it('installs no timer and returns a no-op cleanup when interval is null', () => {
const setNow = vi.fn()
const { timers, setInterval, clearInterval } = fakeTimers()
const cleanup = scheduleNowTicks(null, setNow, timers)
expect(setInterval).not.toHaveBeenCalled()
cleanup()
expect(clearInterval).not.toHaveBeenCalled()
})

it('installs no timer when interval is zero or negative', () => {
const setNow = vi.fn()
for (const bad of [0, -1, -1000]) {
const { timers, setInterval } = fakeTimers()
scheduleNowTicks(bad, setNow, timers)
expect(setInterval, `interval=${bad}`).not.toHaveBeenCalled()
}
})

it('installs an interval that calls setNow with the current time', () => {
const setNow = vi.fn()
const { timers, setInterval, now } = fakeTimers()
scheduleNowTicks(1000, setNow, timers)
expect(setInterval).toHaveBeenCalledTimes(1)
expect(setInterval.mock.calls[0][1]).toBe(1000)

// Fire the registered tick — the hook must update state with
// the latest wall-clock value, NOT the value captured at
// mount.
const tick = setInterval.mock.calls[0][0] as () => void
now.mockReturnValue(1700000005000)
tick()
expect(setNow).toHaveBeenCalledWith(1700000005000)
now.mockReturnValue(1700000010000)
tick()
expect(setNow).toHaveBeenLastCalledWith(1700000010000)
})

it('returns a cleanup that clears the registered timer id', () => {
const setNow = vi.fn()
const { timers, setInterval, clearInterval } = fakeTimers()
setInterval.mockReturnValue(42)
const cleanup = scheduleNowTicks(1000, setNow, timers)
cleanup()
expect(clearInterval).toHaveBeenCalledWith(42)
})

it('passes the interval through unchanged (1Hz vs 60Hz callers)', () => {
const setNow = vi.fn()
const { timers, setInterval } = fakeTimers()
scheduleNowTicks(60_000, setNow, timers)
expect(setInterval.mock.calls[0][1]).toBe(60_000)
})
})
Comment thread
cursor[bot] marked this conversation as resolved.
80 changes: 80 additions & 0 deletions packages/k8s-ui/src/hooks/useNow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'

/**
* Pure predicate for whether `useNow` should schedule a tick for the
* given interval. Extracted so the scheduling rule (null and
* non-positive intervals opt out; anything > 0 ticks) can be
* unit-tested without a React renderer.
*/
export function shouldScheduleNow(intervalMs: number | null): boolean {
if (intervalMs === null) return false
if (intervalMs <= 0) return false
return true
}

/**
* Pure scheduler used by `useNow`. Given the timer primitives, the
* interval, and a setter, it installs an interval that calls
* `setNow(nowFn())` every `intervalMs` and returns a cleanup
* function — or installs nothing and returns a no-op for opt-out
* intervals.
*
* Hoisted so the hook's full effect body (the part that's actually
* worth testing — opt-out vs schedule, the cleanup contract, the
* value passed to setNow) is unit-testable end-to-end without
* needing a React renderer (this package's vitest config has
* neither @testing-library/react nor jsdom).
*/
export function scheduleNowTicks(
intervalMs: number | null,
setNow: (n: number) => void,
timers: {
setInterval: (cb: () => void, ms: number) => unknown
clearInterval: (id: unknown) => void
now: () => number
},
): () => void {
if (!shouldScheduleNow(intervalMs)) {
return () => {}
}
const id = timers.setInterval(() => setNow(timers.now()), intervalMs as number)
return () => timers.clearInterval(id)
}

/**
* Returns the current wall-clock time, refreshed every `intervalMs`.
*
* Use this when you have UI that derives a relative time string
* (e.g. "Updated 8s", "24d") from a fixed timestamp. Without it,
* the relative label only changes when the parent re-renders for
* an unrelated reason — which makes the label feel frozen, and
* worse: the next unrelated re-render makes it jump forward by
* however many seconds passed in silence. Users perceived that as
* a "data re-fetch was triggered" because the displayed age
* suddenly updated.
*
* The interval is opt-in per call site so cells in tight tables
* don't pay the cost of a 1Hz tick when they only need a 60Hz
* update for "minutes" granularity.
*
* @param intervalMs how often to advance the clock. Defaults to
* 1000ms. Pass `null` to disable ticking
* (returns the time at mount and never updates).
* @returns the current `Date.now()` value.
*/
export function useNow(intervalMs: number | null = 1000): number {
const [now, setNow] = useState(() => Date.now())

useEffect(() => {
// Use globalThis for the timer primitives so the hook works
// under SSR / Node-based tests too. In browsers these are the
// same as window.setInterval; in Node, `window` is undefined.
return scheduleNowTicks(intervalMs, setNow, {
setInterval: globalThis.setInterval as (cb: () => void, ms: number) => unknown,
clearInterval: globalThis.clearInterval as (id: unknown) => void,
now: Date.now,
})
}, [intervalMs])

return now
}
Loading