Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
40 changes: 36 additions & 4 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 @@ -2624,12 +2625,31 @@ 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. (SKY-820 / bug 16)
const lastDataRef = useRef<unknown>(undefined)
useEffect(() => {
if (dataUpdatedAt) {
if (dataUpdatedAt && resources !== lastDataRef.current) {
lastDataRef.current = resources
setLastUpdated(new Date(dataUpdatedAt))
}
}, [dataUpdatedAt])
}, [dataUpdatedAt, resources])

// Tick once per second so the "Updated Xs" label advances smoothly
// instead of feeling frozen until some unrelated re-render.
// `formatAge(lastUpdated)` re-reads Date.now(); without this tick the
// label only updates when the parent re-renders for another reason.
useNow(1000)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// Derive counts — prefer lightweight resourceCounts prop over full query data
const counts = useMemo(() => {
Expand Down Expand Up @@ -3953,7 +3973,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. Reported in SKY-820: 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
46 changes: 46 additions & 0 deletions packages/k8s-ui/src/hooks/useNow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { useNow, shouldScheduleNow } from './useNow'

// We don't have @testing-library/react / jsdom in this package's
// vitest setup, so we can't invoke the hook through a renderer here.
// Instead we:
// 1. Import `useNow` so the test fails if the module fails to load
// or the export shape changes (catches accidental signature
// breaks in CI).
// 2. Pin the scheduling predicate `shouldScheduleNow` — extracted
// from the hook precisely so the branch logic that decides
// "tick or don't tick" is unit-testable without a renderer.
//
// (Cursor Bugbot pointed out that a previous iteration of this file
// inlined the branch logic without ever importing `useNow`, giving
// false coverage. This rewrite makes the test reflect what's actually
// shipped.)

describe('useNow', () => {
it('exports a callable hook with the documented signature', () => {
expect(typeof useNow).toBe('function')
// useNow(intervalMs?) — single optional parameter.
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)
})
})
Comment thread
cursor[bot] marked this conversation as resolved.
50 changes: 50 additions & 0 deletions packages/k8s-ui/src/hooks/useNow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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
}

/**
* 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. SKY-820 captured users perceiving 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(() => {
if (!shouldScheduleNow(intervalMs)) return
// Use the global setInterval rather than `window.setInterval` so the
// hook works under SSR / Node-based tests too. In browsers they're
// the same function; in Node, `window` is undefined.
const id = setInterval(() => setNow(Date.now()), intervalMs as number)
return () => {
clearInterval(id)
}
}, [intervalMs])

return now
}
Loading