- **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:
263
frontend/src/App.tsx
Normal file
263
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
50
frontend/src/hooks/useTags.ts
Normal file
50
frontend/src/hooks/useTags.ts
Normal 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 }
|
||||
}
|
||||
87
frontend/src/hooks/useUpdates.ts
Normal file
87
frontend/src/hooks/useUpdates.ts
Normal 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
47
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
41
frontend/src/lib/serviceIcons.json
Normal file
41
frontend/src/lib/serviceIcons.json
Normal 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"
|
||||
}
|
||||
31
frontend/src/lib/serviceIcons.ts
Normal file
31
frontend/src/lib/serviceIcons.ts
Normal 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
17
frontend/src/lib/time.ts
Normal 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`
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
12
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
34
frontend/src/types/diun.ts
Normal file
34
frontend/src/types/diun.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user