diff --git a/packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx b/packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx
index cd2d9cb3d..db8d4b332 100644
--- a/packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx
+++ b/packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx
@@ -26,6 +26,7 @@ import {
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
import { Tooltip } from '../ui/Tooltip'
import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
+import { UpdatedAtLabel } from '../ui/UpdatedAtLabel'
import { getGitOpsResourceStatus } from './detail-helpers'
import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
import { toggleSet } from './GitOpsGraphFilterRail'
@@ -164,6 +165,13 @@ export interface GitOpsTableViewProps {
counts: Record
// Caller refresh — typically invalidates its useQuery + refetches.
onRefresh?: () => void
+ // When set, an "Updated N ago" freshness label renders next to the refresh
+ // button (epoch ms of the last successful load — e.g. React Query's
+ // dataUpdatedAt). Keeps freshness in the existing toolbar, no separate band.
+ dataUpdatedAt?: number
+ // Spins the refresh button during background refetches (vs. `loading`, which
+ // only covers the first load). Optional.
+ isFetching?: boolean
// Row click — caller routes to its own detail page. When the host also
// passes `rowHrefFor`, the callback receives the MouseEvent so it can
// `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
@@ -234,6 +242,8 @@ export function GitOpsTableView({
error,
counts,
onRefresh,
+ dataUpdatedAt,
+ isFetching,
onRowClick,
rowHrefFor,
onDestinationClick,
@@ -633,6 +643,7 @@ export function GitOpsTableView({
setViewMode('table')} />
setViewMode('tiles')} />
+ {dataUpdatedAt != null && }
{onRefresh && (
)}
diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx
index febd27eb6..ee001cf79 100644
--- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx
+++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx
@@ -127,6 +127,7 @@ import {
} from './resource-utils'
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
import { pluralize } from '../../utils/pluralize'
+import { formatLastUpdatedBucket, msToNextBucket } from '../../utils/format'
import { Tooltip } from '../ui/Tooltip'
// CRD-specific cell components (extracted)
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
@@ -1861,27 +1862,6 @@ function getInitialFiltersFromURL() {
// Sort state type
type SortDirection = 'asc' | 'desc' | null
-// Coarse "just now / Xm / Xh / Xd" buckets — finer-grained updates
-// add motion in the periphery without aiding any user decision.
-function formatLastUpdatedBucket(elapsedMs: number): string {
- const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
- if (elapsedSec < 60) return 'just now'
- const minutes = Math.floor(elapsedSec / 60)
- if (minutes < 60) return `${minutes}m`
- const hours = Math.floor(minutes / 60)
- if (hours < 24) return `${hours}h`
- return `${Math.floor(hours / 24)}d`
-}
-
-// ms until the displayed bucket would change.
-function msToNextBucket(elapsedMs: number): number {
- const elapsed = Math.max(0, elapsedMs)
- if (elapsed < 60_000) return 60_000 - elapsed
- if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
- if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
- return 86_400_000 - (elapsed % 86_400_000)
-}
-
// Isolated subtree so re-renders don't cascade into the parent's
// virtualized table.
function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
diff --git a/packages/k8s-ui/src/components/ui/UpdatedAtLabel.tsx b/packages/k8s-ui/src/components/ui/UpdatedAtLabel.tsx
new file mode 100644
index 000000000..41d2169b9
--- /dev/null
+++ b/packages/k8s-ui/src/components/ui/UpdatedAtLabel.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from 'react'
+import { clsx } from 'clsx'
+import { formatUpdatedAgo, msToNextBucket } from '../../utils/format'
+
+interface UpdatedAtLabelProps {
+ // Epoch ms of the last successful load — e.g. React Query's dataUpdatedAt.
+ dataUpdatedAt: number
+ className?: string
+}
+
+// Standalone "Updated N ago" freshness label. Re-renders exactly when the
+// displayed bucket would change (not every second) via msToNextBucket. Pair it
+// with a host-owned refresh button (the GitOps table and Audit header both do).
+export function UpdatedAtLabel({ dataUpdatedAt, className }: UpdatedAtLabelProps) {
+ const [, force] = useState(0)
+
+ useEffect(() => {
+ if (dataUpdatedAt <= 0) return
+ let id: ReturnType
+ function schedule() {
+ const delay = Math.max(1000, msToNextBucket(Date.now() - dataUpdatedAt))
+ id = setTimeout(() => {
+ force((t) => t + 1)
+ schedule()
+ }, delay)
+ }
+ schedule()
+ return () => clearTimeout(id)
+ }, [dataUpdatedAt])
+
+ const label = dataUpdatedAt > 0 ? `Updated ${formatUpdatedAgo(Date.now() - dataUpdatedAt)}` : 'Not yet loaded'
+
+ return (
+
+ {label}
+
+ )
+}
diff --git a/packages/k8s-ui/src/components/ui/index.ts b/packages/k8s-ui/src/components/ui/index.ts
index 2b31f1554..2af6497cd 100644
--- a/packages/k8s-ui/src/components/ui/index.ts
+++ b/packages/k8s-ui/src/components/ui/index.ts
@@ -1,4 +1,5 @@
export { Tooltip } from './Tooltip'
+export { UpdatedAtLabel } from './UpdatedAtLabel'
export { PaneLoader } from './PaneLoader'
export { ClusterName } from './ClusterName'
export { MiddleEllipsis } from './MiddleEllipsis'
diff --git a/packages/k8s-ui/src/utils/format.ts b/packages/k8s-ui/src/utils/format.ts
index 6953567f1..12a214d98 100644
--- a/packages/k8s-ui/src/utils/format.ts
+++ b/packages/k8s-ui/src/utils/format.ts
@@ -192,6 +192,38 @@ export function formatCompactAge(value?: string): string {
return `${Math.floor(hours / 24)}d`
}
+// Coarse "just now / Xm / Xh / Xd" buckets for freshness labels — finer-grained
+// updates add motion in the periphery without aiding any user decision.
+export function formatLastUpdatedBucket(elapsedMs: number): string {
+ const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
+ if (elapsedSec < 60) return 'just now'
+ const minutes = Math.floor(elapsedSec / 60)
+ if (minutes < 60) return `${minutes}m`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h`
+ return `${Math.floor(hours / 24)}d`
+}
+
+// ms until the bucket produced by formatLastUpdatedBucket would change — lets a
+// ticker re-render exactly on the boundary instead of polling every second.
+export function msToNextBucket(elapsedMs: number): number {
+ const elapsed = Math.max(0, elapsedMs)
+ if (elapsed < 60_000) return 60_000 - elapsed
+ if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
+ if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
+ return 86_400_000 - (elapsed % 86_400_000)
+}
+
+// Freshness phrasing for "Updated X" indicators: just now / Xm ago / Xh ago /
+// over a day ago. An exact day count is noise for an auto-refresh signal, so
+// anything past 24h collapses to "over a day ago".
+export function formatUpdatedAgo(elapsedMs: number): string {
+ const bucket = formatLastUpdatedBucket(elapsedMs)
+ if (bucket === 'just now') return 'just now'
+ if (bucket.endsWith('d')) return 'over a day ago'
+ return `${bucket} ago`
+}
+
export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
if (!value) return fallback
const time = Date.parse(value)
diff --git a/web/src/components/audit/AuditView.tsx b/web/src/components/audit/AuditView.tsx
index f6e39a165..a865f3a3b 100644
--- a/web/src/components/audit/AuditView.tsx
+++ b/web/src/components/audit/AuditView.tsx
@@ -1,8 +1,9 @@
import { useState, useCallback } from 'react'
import { useAudit, useAuditSettings, useUpdateAuditSettings } from '../../api/client'
import type { SelectedResource } from '../../types'
-import { ChecksView, PaneLoader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
-import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
+import { ChecksView, PaneLoader, UpdatedAtLabel, useRefreshAnimation, type CheckResourceRef } from '@skyhook-io/k8s-ui'
+import { ArrowLeft, ClipboardCheck, RefreshCw, Settings } from 'lucide-react'
+import { clsx } from 'clsx'
import { AuditSettingsDialog } from './AuditSettingsDialog'
interface AuditViewProps {
@@ -18,13 +19,16 @@ interface AuditViewProps {
// ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
// them.
export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditViewProps) {
- const { data, isLoading, error } = useAudit(namespaces)
+ const { data, isLoading, error, isFetching, dataUpdatedAt, refetch } = useAudit(namespaces)
const { data: auditSettings } = useAuditSettings()
const updateSettings = useUpdateAuditSettings()
const [showSettings, setShowSettings] = useState(false)
const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
+ const [refresh, isAnimating] = useRefreshAnimation(() => refetch())
+ const spinning = isFetching || isAnimating
+
// Inline hide actions — persist to local settings immediately.
const hideCheck = useCallback((checkID: string) => {
if (!auditSettings) return
@@ -87,6 +91,18 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
+
+
+
+
{ignoredCount > 0 && (
)}
diff --git a/web/src/components/gitops/GitOpsView.tsx b/web/src/components/gitops/GitOpsView.tsx
index 09f7912d0..fcc9f525b 100644
--- a/web/src/components/gitops/GitOpsView.tsx
+++ b/web/src/components/gitops/GitOpsView.tsx
@@ -194,7 +194,8 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
// inviting a duplicate request. Radar serves reads from an informer cache that
// lags the write by the watch-propagation delay, so refetch once now (covers
// an already-current cache) and once shortly after to catch the propagated
- // update; refetch() forces a fetch regardless of staleTime.
+ // update; refetch() forces a fetch regardless of staleTime. The toolbar's
+ // manual refresh reuses refetchTable so rows + counts stay in sync.
const refetchTable = () => {
rowsQuery.refetch()
countsQuery.refetch()
@@ -249,7 +250,9 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
error={(rowsQuery.error as Error | null) ?? null}
counts={countsQuery.data?.counts ?? {}}
- onRefresh={() => rowsQuery.refetch()}
+ dataUpdatedAt={rowsQuery.dataUpdatedAt}
+ isFetching={rowsQuery.isFetching || countsQuery.isFetching}
+ onRefresh={refetchTable}
onRowClick={(row) => {
const ns = row.namespace || '_'
const params = new URLSearchParams()