feat: surface inventory timestamps on nodes

Add creation_date, last_scan and last_modify to the node inventory and the
right-hand detail panel (last_seen already shown):

- creation_date  -> existing created_at column
- last_modify    -> existing updated_at column (bumped on any node change)
- last_scan      -> new column, stamped when a scan observes a node by IP/MAC
- last_seen      -> unchanged

Backend: new nodes.last_scan column + idempotent migration, NodeResponse
field, scanner stamps matching canvas nodes per scanned host. Frontend:
NodeData fields + DetailPanel rows with UTC-safe timestamp formatting.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-06-27 03:13:49 +02:00
parent b3f7c38c67
commit 19b7d38ec0
10 changed files with 178 additions and 3 deletions
+11 -1
View File
@@ -14,6 +14,13 @@ interface DetailPanelProps {
onEdit: (id: string) => void
}
// Backend timestamps may arrive without a timezone suffix (naive UTC). Append
// 'Z' when absent so the browser parses them as UTC rather than local time.
function formatTimestamp(value: string): string {
const iso = /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z'
return new Date(iso).toLocaleString()
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
@@ -252,7 +259,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
{data.last_seen && <DetailRow label="Last Seen" value={formatTimestamp(data.last_seen)} />}
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
</div>
{/* Size section — manual width/height entry for pixel-exact sizing */}
@@ -212,6 +212,37 @@ describe('DetailPanel', () => {
})
})
describe('Inventory timestamps', () => {
it('renders Created, Last Scan and Last Modified rows when present', () => {
setupStore({
created_at: '2026-01-02T10:00:00Z',
last_scan: '2026-06-01T08:30:00Z',
updated_at: '2026-06-20T12:00:00Z',
last_seen: '2026-06-25T09:15:00Z',
})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.getByText('Last Scan')).toBeInTheDocument()
expect(screen.getByText('Last Modified')).toBeInTheDocument()
expect(screen.getByText('Last Seen')).toBeInTheDocument()
})
it('omits rows whose timestamps are absent', () => {
setupStore({ created_at: '2026-01-02T10:00:00Z' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.queryByText('Last Scan')).not.toBeInTheDocument()
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument()
})
it('parses naive (suffix-less) UTC timestamps without throwing', () => {
setupStore({ created_at: '2026-01-02 10:00:00' })
render(<DetailPanel onEdit={vi.fn()} />)
// Renders a locale string for the date (year is locale-independent).
expect(screen.getByText(/2026/)).toBeInTheDocument()
})
})
describe('Panel actions', () => {
it('calls setSelectedNode(null) when close button is clicked', () => {
const setSelectedNode = vi.fn()
+3
View File
@@ -102,6 +102,9 @@ export interface NodeData extends Record<string, unknown> {
check_target?: string
services: ServiceInfo[]
last_seen?: string
last_scan?: string
created_at?: string
updated_at?: string
response_time_ms?: number
notes?: string
cpu_count?: number