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
13 changes: 12 additions & 1 deletion packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -164,6 +165,13 @@ export interface GitOpsTableViewProps {
counts: Record<string, number>
// 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
Expand Down Expand Up @@ -234,6 +242,8 @@ export function GitOpsTableView({
error,
counts,
onRefresh,
dataUpdatedAt,
isFetching,
onRowClick,
rowHrefFor,
onDestinationClick,
Expand Down Expand Up @@ -633,14 +643,15 @@ export function GitOpsTableView({
<GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
<GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
</div>
{dataUpdatedAt != null && <UpdatedAtLabel dataUpdatedAt={dataUpdatedAt} />}
{onRefresh && (
<Tooltip content="Refresh GitOps resources">
<button
type="button"
onClick={onRefresh}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-3.5 w-3.5 ${loading || isFetching ? 'animate-spin' : ''}`} />
</button>
</Tooltip>
)}
Expand Down
22 changes: 1 addition & 21 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }) {
Expand Down
38 changes: 38 additions & 0 deletions packages/k8s-ui/src/components/ui/UpdatedAtLabel.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>
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 (
<span className={clsx('whitespace-nowrap tabular-nums text-xs text-theme-text-tertiary', className)}>
{label}
</span>
)
}
1 change: 1 addition & 0 deletions packages/k8s-ui/src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Tooltip } from './Tooltip'
export { UpdatedAtLabel } from './UpdatedAtLabel'
export { PaneLoader } from './PaneLoader'
export { ClusterName } from './ClusterName'
export { MiddleEllipsis } from './MiddleEllipsis'
Expand Down
32 changes: 32 additions & 0 deletions packages/k8s-ui/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 19 additions & 3 deletions web/src/components/audit/AuditView.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -87,6 +91,18 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<UpdatedAtLabel dataUpdatedAt={dataUpdatedAt} />
<button
onClick={refresh}
disabled={spinning}
className="p-1.5 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors disabled:opacity-50"
title="Refresh now"
aria-label="Refresh now"
>
<RefreshCw className={clsx('w-3.5 h-3.5', spinning && 'animate-spin')} />
</button>
</div>
{ignoredCount > 0 && (
<button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
)}
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/gitops/GitOpsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading