- **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

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 }