- **refactor(main):** migrate static HTML to React components
Some checks failed
CI / build-test (push) Successful in 1m25s
CI / docker (push) Failing after 1s

- **feat(ui):** implement `AcknowledgeButton` component for acknowledging images
- **feat(stats):** add dashboard stats for total images, pending updates, and acknowledged status
- **chore(deps):** introduce `bun` dependency management and add required libraries
- **style(ui):** enhance UI with Tailwind-based components and modularity improvements
- **chore:** add drag-and-drop tag assignment using `@dnd-kit/core`
This commit is contained in:
2026-02-25 20:37:15 +01:00
parent 54478dcd4f
commit 6094edc5c8
40 changed files with 2395 additions and 99 deletions

263
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,263 @@
import React, { useState, useRef } from 'react'
import { Box, ArrowUp, CheckCheck, Clock, Plus } from 'lucide-react'
import { DndContext, type DragEndEvent } from '@dnd-kit/core'
import { useUpdates } from '@/hooks/useUpdates'
import { useTags } from '@/hooks/useTags'
import { Header } from '@/components/Header'
import { TagSection } from '@/components/TagSection'
import { timeAgo } from '@/lib/time'
import { cn } from '@/lib/utils'
function StatCard({
label,
value,
subtitle,
icon,
highlight,
isText,
}: {
label: string
value: number | string
subtitle: string
icon: React.ReactNode
highlight?: boolean
isText?: boolean
}) {
return (
<div className="p-5 rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{label}
</span>
<span className={cn('h-5 w-5', highlight ? 'text-amber-500' : 'text-muted-foreground')}>
{icon}
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<span
className={cn(
'font-bold tracking-tight',
isText ? 'text-2xl' : 'text-3xl',
highlight && value !== 0 ? 'text-amber-500' : '',
)}
>
{value}
</span>
{subtitle && (
<span
className={cn(
'text-xs font-medium',
highlight && value !== 0 ? 'text-amber-500' : 'text-muted-foreground',
)}
>
{subtitle}
</span>
)}
</div>
</div>
)
}
export default function App() {
const {
updates,
loading,
error,
lastRefreshed,
secondsUntilRefresh,
fetchUpdates,
acknowledge,
assignTag,
} = useUpdates()
const { tags, createTag, deleteTag } = useTags()
const [showNewGroupInput, setShowNewGroupInput] = useState(false)
const [newGroupName, setNewGroupName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const entries = Object.entries(updates)
const pending = entries.filter(([, e]) => !e.acknowledged).length
const acknowledgedCount = entries.filter(([, e]) => e.acknowledged).length
const lastReceived = entries.reduce<string | null>((acc, [, e]) => {
if (!acc || e.received_at > acc) return e.received_at
return acc
}, null)
// Group entries by tag
const taggedSections = tags.map(tag => ({
tag,
rows: entries
.filter(([, e]) => e.tag?.id === tag.id)
.map(([image, entry]) => ({ image, entry })),
}))
const untaggedRows = entries
.filter(([, e]) => !e.tag)
.map(([image, entry]) => ({ image, entry }))
function handleDragEnd({ active, over }: DragEndEvent) {
if (!over) return
const image = active.id as string
if (over.id === 'untagged') {
assignTag(image, null)
return
}
const tagId = parseInt((over.id as string).replace('tag-', ''), 10)
const found = tags.find(t => t.id === tagId) ?? null
assignTag(image, found)
}
function handleNewGroupSubmit() {
const name = newGroupName.trim()
if (!name) return
createTag(name)
setNewGroupName('')
setShowNewGroupInput(false)
}
function handleNewGroupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') handleNewGroupSubmit()
if (e.key === 'Escape') {
setShowNewGroupInput(false)
setNewGroupName('')
}
}
const hasSections = taggedSections.length > 0 || untaggedRows.length > 0
return (
<div className="min-h-screen flex flex-col bg-background">
<Header onRefresh={fetchUpdates} />
<main className="flex-1 max-w-[1200px] mx-auto w-full px-6 py-8 space-y-10">
{/* Stats row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total Images"
value={entries.length}
subtitle="tracked"
icon={<Box className="h-5 w-5" />}
/>
<StatCard
label="Pending Updates"
value={pending}
subtitle="need attention"
icon={<ArrowUp className="h-5 w-5" />}
highlight={pending > 0}
/>
<StatCard
label="Acknowledged"
value={acknowledgedCount}
subtitle="reviewed"
icon={<CheckCheck className="h-5 w-5" />}
/>
<StatCard
label="Last Received"
value={lastReceived ? timeAgo(lastReceived) : '—'}
subtitle={lastReceived ? '' : 'no data'}
icon={<Clock className="h-5 w-5" />}
isText
/>
</div>
{/* Loading state */}
{loading && (
<div className="text-center text-muted-foreground py-8">Loading</div>
)}
{/* Error state */}
{error && !loading && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Error: {error}
</div>
)}
{/* Empty state */}
{!loading && !hasSections && !error && (
<div className="text-center text-muted-foreground py-16">
No updates received yet.
</div>
)}
{/* Sections */}
{!loading && (
<div className="space-y-2">
{/* New group control */}
<div className="flex items-center gap-3 pb-4">
{showNewGroupInput ? (
<div className="flex items-center gap-2">
<input
ref={inputRef}
autoFocus
type="text"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
onKeyDown={handleNewGroupKeyDown}
placeholder="Group name…"
className="h-8 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 w-44"
/>
<button
onClick={handleNewGroupSubmit}
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors"
>
Create
</button>
<button
onClick={() => { setShowNewGroupInput(false); setNewGroupName('') }}
className="h-8 px-2 rounded-md text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowNewGroupInput(true)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
New group
</button>
)}
</div>
<DndContext onDragEnd={handleDragEnd}>
<div className="space-y-12">
{taggedSections.map(({ tag, rows }) => (
<TagSection
key={tag.id}
tag={tag}
rows={rows}
onAcknowledge={acknowledge}
onDeleteTag={deleteTag}
/>
))}
{(untaggedRows.length > 0 || taggedSections.length > 0) && (
<TagSection
tag={null}
rows={untaggedRows}
onAcknowledge={acknowledge}
/>
)}
</div>
</DndContext>
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-border px-6 py-6 max-w-[1200px] mx-auto w-full">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-[11px] font-medium text-muted-foreground tracking-tight uppercase">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
Connected · Polling every 5s
</div>
<div className="flex items-center gap-6">
{lastRefreshed && (
<span>Last Sync: {lastRefreshed.toLocaleTimeString()}</span>
)}
<span>Next in {secondsUntilRefresh}s</span>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface AcknowledgeButtonProps {
image: string
onAcknowledge: (image: string) => void
}
export function AcknowledgeButton({ image, onAcknowledge }: AcknowledgeButtonProps) {
return (
<Button
variant="ghost"
size="sm"
onClick={() => onAcknowledge(image)}
title="Mark as acknowledged"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
>
<Check className="h-4 w-4" />
</Button>
)
}

View File

@@ -0,0 +1,28 @@
import { RefreshCw, Container } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface HeaderProps {
onRefresh: () => void
}
export function Header({ onRefresh }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur-md">
<div className="max-w-[1200px] mx-auto px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Container className="h-5 w-5 text-foreground shrink-0" />
<span className="font-bold tracking-tight text-lg">Diun Dashboard</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</header>
)
}

View File

@@ -0,0 +1,149 @@
import { ArrowUp, GripVertical } from 'lucide-react'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Badge } from '@/components/ui/badge'
import { AcknowledgeButton } from '@/components/AcknowledgeButton'
import { timeAgo } from '@/lib/time'
import { cn } from '@/lib/utils'
import type { UpdateEntry } from '@/types/diun'
import { getServiceIcon } from '@/lib/serviceIcons'
interface ServiceCardProps {
image: string
entry: UpdateEntry
onAcknowledge: (image: string) => void
}
function getInitials(image: string): string {
const afterLastSlash = image.split('/').pop() ?? image
const withoutTag = afterLastSlash.split(':')[0]
return withoutTag.slice(0, 2).toUpperCase()
}
function getTag(image: string): string {
return image.split(':')[1] ?? 'latest'
}
function getShortName(image: string): string {
const withoutTag = image.split(':')[0]
const parts = withoutTag.split('/')
if (parts.length === 1) return withoutTag
// strip registry (first part if it contains a dot or colon)
const first = parts[0]
const hasRegistry = first.includes('.') || first.includes(':') || first === 'localhost'
const meaningful = hasRegistry ? parts.slice(1) : parts
return meaningful.join('/')
}
function getRegistry(image: string): string {
const parts = image.split('/')
if (parts.length === 1) return 'Docker Hub'
const first = parts[0]
if (!first.includes('.') && !first.includes(':') && first !== 'localhost') return 'Docker Hub'
if (first === 'ghcr.io') return 'GitHub'
if (first === 'gcr.io') return 'GCR'
return first
}
export function ServiceCard({ image, entry, onAcknowledge }: ServiceCardProps) {
const initials = getInitials(image)
const icon = getServiceIcon(image)
const tag = getTag(image)
const shortName = getShortName(image)
const registry = getRegistry(image)
const isUpdate = entry.event.status === 'update'
const isNew = entry.event.status === 'new'
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: image })
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group p-4 rounded-xl border border-border bg-card hover:border-muted-foreground/30 transition-all flex flex-col justify-between gap-4',
isDragging && 'opacity-30',
)}
>
{/* Top row */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg border border-border bg-secondary flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
{icon ? (
<svg
viewBox="0 0 24 24"
className="w-5 h-5"
style={{ fill: `#${icon.hex}` }}
aria-label={icon.title}
>
<path d={icon.path} />
</svg>
) : (
<span className="font-mono text-xs font-bold">{initials}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm leading-none tracking-tight truncate" title={shortName}>
{shortName}
</p>
<p className="text-[10px] text-muted-foreground mt-1 truncate" title={registry}>
{registry}
</p>
</div>
<button
{...attributes}
{...listeners}
className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none"
aria-label="Drag to regroup"
>
<GripVertical className="h-4 w-4" />
</button>
</div>
{/* Bottom row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" className="text-[10px] font-mono px-2 py-0.5">
{tag}
</Badge>
{!entry.acknowledged && isUpdate && (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/15 text-green-400 border border-green-500/25 flex items-center gap-1">
<ArrowUp className="h-3 w-3" />
UPDATE
</span>
)}
{!entry.acknowledged && isNew && (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/15 text-green-400 border border-green-500/25 flex items-center gap-1">
<ArrowUp className="h-3 w-3" />
UPDATE AVAILABLE
</span>
)}
{!entry.acknowledged && !isUpdate && !isNew && (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/15 text-green-400 border border-green-500/25 flex items-center gap-1">
<ArrowUp className="h-3 w-3" />
{entry.event.status.toUpperCase()}
</span>
)}
{entry.acknowledged && (
<span className="text-[10px] text-muted-foreground font-medium">
checked acknowledged
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-muted-foreground">
{timeAgo(entry.received_at)}
</span>
{!entry.acknowledged && (
<AcknowledgeButton image={image} onAcknowledge={onAcknowledge} />
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { Layers, Trash2 } from 'lucide-react'
import { useDroppable } from '@dnd-kit/core'
import { Badge } from '@/components/ui/badge'
import { ServiceCard } from '@/components/ServiceCard'
import { cn } from '@/lib/utils'
import type { Tag, UpdateEntry } from '@/types/diun'
export interface TagSectionRow {
image: string
entry: UpdateEntry
}
interface TagSectionProps {
tag: Tag | null
rows: TagSectionRow[]
onAcknowledge: (image: string) => void
onDeleteTag?: (id: number) => void
}
export function TagSection({ tag, rows, onAcknowledge, onDeleteTag }: TagSectionProps) {
const droppableId = tag ? `tag-${tag.id}` : 'untagged'
const { setNodeRef, isOver } = useDroppable({ id: droppableId })
const [confirmDelete, setConfirmDelete] = useState(false)
function handleDeleteClick() {
if (!confirmDelete) {
setConfirmDelete(true)
return
}
if (tag && onDeleteTag) {
onDeleteTag(tag.id)
}
}
return (
<section
ref={setNodeRef}
className={cn(
'space-y-4 rounded-xl p-4 transition-all',
isOver && 'ring-2 ring-primary/30',
)}
>
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<Layers className="h-5 w-5 text-muted-foreground shrink-0" />
<h3 className="font-bold tracking-tight text-base">
{tag ? tag.name : 'Untagged'}
</h3>
<Badge variant="outline" className="text-[10px] font-semibold px-2 py-0.5 tracking-wide">
{rows.length} {rows.length === 1 ? 'Service' : 'Services'}
</Badge>
</div>
{tag && onDeleteTag && (
<button
onClick={handleDeleteClick}
onBlur={() => setConfirmDelete(false)}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors',
confirmDelete
? 'text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100',
)}
>
<Trash2 className="h-3.5 w-3.5" />
{confirmDelete ? 'Sure?' : 'Delete'}
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{rows.map(({ image, entry }) => (
<ServiceCard
key={image}
image={image}
entry={entry}
onAcknowledge={onAcknowledge}
/>
))}
{rows.length === 0 && (
<div className={cn(
'col-span-full rounded-lg border-2 border-dashed border-border py-8 text-center text-sm text-muted-foreground',
isOver && 'border-primary/40 bg-primary/5',
)}>
Drop services here
</div>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,35 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,51 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
}
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />
}
export { Card, CardHeader, CardTitle, CardContent }

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback } from 'react'
import type { Tag } from '@/types/diun'
export function useTags() {
const [tags, setTags] = useState<Tag[]>([])
const fetchTags = useCallback(async () => {
try {
const res = await fetch('/api/tags')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: Tag[] = await res.json()
setTags(data)
} catch (e) {
console.error('Failed to fetch tags:', e)
}
}, [])
useEffect(() => {
fetchTags()
}, [fetchTags])
const createTag = useCallback(async (name: string): Promise<Tag | null> => {
try {
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const tag: Tag = await res.json()
setTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)))
return tag
} catch (e) {
console.error('Failed to create tag:', e)
return null
}
}, [])
const deleteTag = useCallback(async (id: number) => {
try {
const res = await fetch(`/api/tags/${id}`, { method: 'DELETE' })
if (!res.ok && res.status !== 404) throw new Error(`HTTP ${res.status}`)
setTags(prev => prev.filter(t => t.id !== id))
} catch (e) {
console.error('Failed to delete tag:', e)
}
}, [])
return { tags, createTag, deleteTag }
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react'
import type { Tag, UpdatesMap } from '@/types/diun'
const POLL_INTERVAL = 5000
export function useUpdates() {
const [updates, setUpdates] = useState<UpdatesMap>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null)
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(POLL_INTERVAL / 1000)
const fetchUpdates = useCallback(async () => {
try {
const res = await fetch('/api/updates')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: UpdatesMap = await res.json()
setUpdates(data)
setError(null)
setLastRefreshed(new Date())
setSecondsUntilRefresh(POLL_INTERVAL / 1000)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to fetch updates')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUpdates()
const pollTimer = setInterval(fetchUpdates, POLL_INTERVAL)
return () => clearInterval(pollTimer)
}, [fetchUpdates])
useEffect(() => {
const countdownTimer = setInterval(() => {
setSecondsUntilRefresh(s => Math.max(0, s - 1))
}, 1000)
return () => clearInterval(countdownTimer)
}, [])
const acknowledge = useCallback(async (image: string) => {
try {
const res = await fetch(`/api/updates/${encodeURIComponent(image)}`, {
method: 'PATCH',
})
if (!res.ok && res.status !== 404) {
throw new Error(`HTTP ${res.status}`)
}
setUpdates(prev => {
const entry = prev[image]
if (!entry) return prev
return { ...prev, [image]: { ...entry, acknowledged: true } }
})
} catch (e) {
console.error('Acknowledge failed:', e)
}
}, [])
const assignTag = useCallback(async (image: string, tag: Tag | null) => {
// Optimistic update
setUpdates(prev => {
const entry = prev[image]
if (!entry) return prev
return { ...prev, [image]: { ...entry, tag } }
})
try {
if (tag === null) {
await fetch('/api/tag-assignments', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
})
} else {
await fetch('/api/tag-assignments', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image, tag_id: tag.id }),
})
}
} catch (e) {
console.error('assignTag failed:', e)
}
}, [])
return { updates, loading, error, lastRefreshed, secondsUntilRefresh, fetchUpdates, acknowledge, assignTag }
}

47
frontend/src/index.css Normal file
View File

@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,41 @@
{
"nextcloud": "nextcloud",
"postgres": "postgresql",
"postgresql": "postgresql",
"redis": "redis",
"nginx": "nginx",
"mysql": "mysql",
"mariadb": "mariadb",
"grafana": "grafana",
"prometheus": "prometheus",
"traefik": "traefik",
"alpine": "alpinelinux",
"ubuntu": "ubuntu",
"debian": "debian",
"node": "nodedotjs",
"python": "python",
"golang": "go",
"elasticsearch": "elasticsearch",
"kibana": "kibana",
"portainer": "portainer",
"gitea": "gitea",
"gitlab": "gitlab",
"jellyfin": "jellyfin",
"plex": "plex",
"rabbitmq": "rabbitmq",
"mongodb": "mongodb",
"influxdb": "influxdb",
"bitwarden": "bitwarden",
"vaultwarden": "bitwarden",
"wordpress": "wordpress",
"homeassistant": "homeassistant",
"ghost": "ghost",
"caddy": "caddy",
"keycloak": "keycloak",
"adguard": "adguard",
"adguardhome": "adguard",
"wireguard": "wireguard",
"syncthing": "syncthing",
"pihole": "pihole",
"sonarqube": "sonarqube"
}

View File

@@ -0,0 +1,31 @@
import * as SimpleIcons from 'simple-icons'
import mapping from './serviceIcons.json'
// simple-icons exports named icons as siSlugInPascalCase e.g. siPostgresql
function slugToKey(slug: string): string {
return 'si' + slug.charAt(0).toUpperCase() + slug.slice(1)
}
export interface ServiceIcon {
title: string
hex: string // e.g. "4169E1" (no leading #)
path: string // SVG path data for a 24×24 viewBox
}
/**
* Given a Docker image string (e.g. "ghcr.io/linuxserver/nextcloud:28-alpine"),
* returns icon data or null for unknown images.
*/
export function getServiceIcon(image: string): ServiceIcon | null {
// Strip tag, take last path segment
const base = image.split(':')[0]
const name = base.split('/').pop() ?? base
// Normalise: lowercase, strip common Docker image suffixes
const normalised = name.toLowerCase().replace(/[_-](ce|oss|alpine|fpm|slim|lts)$/, '')
const slug = (mapping as Record<string, string>)[normalised]
if (!slug) return null
const icon = (SimpleIcons as Record<string, ServiceIcon | undefined>)[slugToKey(slug)]
return icon ?? null
}

17
frontend/src/lib/time.ts Normal file
View File

@@ -0,0 +1,17 @@
export function timeAgo(iso: string): string {
const now = Date.now()
const then = new Date(iso).getTime()
const diff = Math.floor((now - then) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) {
const m = Math.floor(diff / 60)
return `${m} minute${m !== 1 ? 's' : ''} ago`
}
if (diff < 86400) {
const h = Math.floor(diff / 3600)
return `${h} hour${h !== 1 ? 's' : ''} ago`
}
const d = Math.floor(diff / 86400)
return `${d} day${d !== 1 ? 's' : ''} ago`
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

12
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
document.documentElement.classList.add('dark')
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,34 @@
export interface DiunEventMetadata {
ctn_names: string
ctn_id: string
ctn_state: string
ctn_status: string
}
export interface DiunEvent {
diun_version: string
hostname: string
status: 'new' | 'update' | string
provider: string
image: string
hub_link: string
mime_type: string
digest: string
created: string
platform: string
metadata: DiunEventMetadata
}
export interface Tag {
id: number
name: string
}
export interface UpdateEntry {
event: DiunEvent
received_at: string
acknowledged: boolean
tag: Tag | null
}
export type UpdatesMap = Record<string, UpdateEntry>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />