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()