- **refactor(main):** migrate static HTML to React components
- **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:
21
frontend/src/components/AcknowledgeButton.tsx
Normal file
21
frontend/src/components/AcknowledgeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/Header.tsx
Normal file
28
frontend/src/components/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
frontend/src/components/ServiceCard.tsx
Normal file
149
frontend/src/components/ServiceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
frontend/src/components/TagSection.tsx
Normal file
91
frontend/src/components/TagSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/ui/badge.tsx
Normal file
35
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
51
frontend/src/components/ui/button.tsx
Normal file
51
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
30
frontend/src/components/ui/card.tsx
Normal file
30
frontend/src/components/ui/card.tsx
Normal 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 }
|
||||
27
frontend/src/components/ui/tooltip.tsx
Normal file
27
frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user