From 1cbd54d0ee3e2566efa7ba3c8ba5c38612f90498 Mon Sep 17 00:00:00 2001 From: alex289 Date: Thu, 29 Oct 2026 19:55:30 +0100 Subject: [PATCH 1/4] feat(ui): redesign resource cards with flow-stage layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent, application, notification, and repository list pages used inconsistent, bespoke card layouts that made it hard to scan status at a glance. Introduce a shared ResourceCard component with a flow visualization showing the key stages each resource passes through (e.g. revision → sync → health), and build dedicated cards for agents, applications, notifications, and repositories on top of it. Badge helper functions are exported so the new cards can reuse status labels. --- frontend/messages/de.json | 14 +- frontend/messages/en.json | 14 +- .../badges/application-status-badge.tsx | 8 + .../badges/notification-status-badge.tsx | 4 +- .../badges/repository-status-badge.tsx | 4 +- frontend/src/components/cards/agent-card.tsx | 56 ++++++ .../src/components/cards/application-card.tsx | 72 +++++++ .../components/cards/notification-card.tsx | 74 +++++++ .../src/components/cards/repository-card.tsx | 77 ++++++++ .../src/components/cards/resource-card.tsx | 186 ++++++++++++++++++ .../routes/_authenticated/agents/index.tsx | 127 ++++-------- .../_authenticated/applications/index.tsx | 63 +----- .../_authenticated/notifications/index.tsx | 156 +++++---------- .../_authenticated/repositories/index.tsx | 158 +++++---------- frontend/src/styles.css | 7 + 15 files changed, 647 insertions(+), 373 deletions(-) create mode 100644 frontend/src/components/cards/agent-card.tsx create mode 100644 frontend/src/components/cards/application-card.tsx create mode 100644 frontend/src/components/cards/notification-card.tsx create mode 100644 frontend/src/components/cards/repository-card.tsx create mode 100644 frontend/src/components/cards/resource-card.tsx diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2febd83a..d329e389 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -84,9 +84,19 @@ "totalRepositoriesCount": "Es gibt insgesamt {count} Repositories", "totalNotificationsCount": "Es gibt insgesamt {count} Benachrichtigungen", "notificationProviderAlt": "Benachrichtigungs-Anbieter", - "appsCount": "Anzahl Apps", "notAvailableShort": "k. A.", "cardActions": "Kartenaktionen", + "cardStageRevision": "Revision", + "cardStageSync": "Sync", + "cardStageHealth": "Zustand", + "cardStageRemote": "Remote", + "cardStageApplications": "Apps", + "cardStageHub": "Hub", + "cardStageDocker": "Docker", + "cardStageChannel": "Kanal", + "cardStageDelivery": "Letzte Zustellung", + "cardDockerUnreachable": "Nicht erreichbar", + "cardEventsCount": "Ereignisse: {count}", "deleteAgentCardTitle": "Agentenkarte löschen?", "deleteAgentCardDescription": "Dadurch wird \"{name}\" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "deleteRepositoryTitle": "Repository löschen?", @@ -722,8 +732,6 @@ "eventStatusSucceeded": "Erfolgreich", "eventStatusFailed": "Fehlgeschlagen", "eventStatusNoChange": "Keine Änderung", - "agentVersion": "Agent-Version", - "dockerVersion": "Docker-Version", "columnAgentVersion": "Agent-Version", "columnDockerVersion": "Docker-Version", "agentVersionOutdated": "Veraltet", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a28161ee..08381060 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -84,9 +84,19 @@ "totalRepositoriesCount": "There are {count} repositories in total", "totalNotificationsCount": "There are {count} notifications in total", "notificationProviderAlt": "Notification provider", - "appsCount": "Apps count", "notAvailableShort": "n/a", "cardActions": "Card actions", + "cardStageRevision": "Revision", + "cardStageSync": "Sync", + "cardStageHealth": "Health", + "cardStageRemote": "Remote", + "cardStageApplications": "Apps", + "cardStageHub": "Hub", + "cardStageDocker": "Docker", + "cardStageChannel": "Channel", + "cardStageDelivery": "Last delivery", + "cardDockerUnreachable": "Unreachable", + "cardEventsCount": "Events: {count}", "deleteAgentCardTitle": "Delete agent card?", "deleteAgentCardDescription": "This will permanently delete \"{name}\". This action cannot be undone.", "deleteRepositoryTitle": "Delete repository?", @@ -722,8 +732,6 @@ "eventStatusSucceeded": "Succeeded", "eventStatusFailed": "Failed", "eventStatusNoChange": "No change", - "agentVersion": "Agent version", - "dockerVersion": "Docker version", "columnAgentVersion": "Agent Version", "columnDockerVersion": "Docker Version", "agentVersionOutdated": "Outdated", diff --git a/frontend/src/components/badges/application-status-badge.tsx b/frontend/src/components/badges/application-status-badge.tsx index f81d1822..c69ff899 100644 --- a/frontend/src/components/badges/application-status-badge.tsx +++ b/frontend/src/components/badges/application-status-badge.tsx @@ -62,6 +62,14 @@ const healthConfig: Record< }, }; +export function getSyncStatusLabel(status: SyncStatus): string { + return syncConfig[status].label(); +} + +export function getHealthStatusLabel(status: HealthStatus): string { + return healthConfig[status].label(); +} + export function ApplicationStatusBadge(props: StatusBadgeProps) { let config = props.type === "sync" diff --git a/frontend/src/components/badges/notification-status-badge.tsx b/frontend/src/components/badges/notification-status-badge.tsx index 2d806511..b5e73df0 100644 --- a/frontend/src/components/badges/notification-status-badge.tsx +++ b/frontend/src/components/badges/notification-status-badge.tsx @@ -2,7 +2,7 @@ import type { NotificationStatus } from "@/lib/notifications"; import { m } from "@/lib/paraglide/messages"; import { Badge } from "../ui/badge"; -function getStatusLabel(status: NotificationStatus): string { +export function getNotificationStatusLabel(status: NotificationStatus): string { switch (status) { case "success": return m.successAlertTitle(); @@ -44,7 +44,7 @@ export function NotificationStatusBadge({ status }: { status: NotificationStatus
); diff --git a/frontend/src/components/badges/repository-status-badge.tsx b/frontend/src/components/badges/repository-status-badge.tsx index 3c801655..402a9890 100644 --- a/frontend/src/components/badges/repository-status-badge.tsx +++ b/frontend/src/components/badges/repository-status-badge.tsx @@ -15,7 +15,7 @@ function getSyncStatusColor(syncStatus: RepositorySyncStatus): string { } } -function getSyncStatusLabel(syncStatus: RepositorySyncStatus): string { +export function getRepositorySyncStatusLabel(syncStatus: RepositorySyncStatus): string { switch (syncStatus) { case "syncing": return m.repoSyncStatusSyncing(); @@ -29,7 +29,7 @@ function getSyncStatusLabel(syncStatus: RepositorySyncStatus): string { } export function RepositoryStatusBadge({ status }: { status: RepositorySyncStatus }) { - const statusLabel = getSyncStatusLabel(status); + const statusLabel = getRepositorySyncStatusLabel(status); const statusDotClass = getSyncStatusColor(status); const badgeVariant = status === "success" ? "success" : status === "failed" ? "destructive" : "secondary"; diff --git a/frontend/src/components/cards/agent-card.tsx b/frontend/src/components/cards/agent-card.tsx new file mode 100644 index 00000000..6a824ca6 --- /dev/null +++ b/frontend/src/components/cards/agent-card.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { AgentVersionBadge } from "@/components/badges/agent-version-badge"; +import { StaticLucideIcon } from "@/components/lucide-icon-picker"; +import { AgentStatus, type Agent } from "@/lib/agents"; +import { m } from "@/lib/paraglide/messages"; +import { ResourceCard, type FlowStage } from "./resource-card"; + +// An agent in the error state is connected to the hub, but cannot reach the Docker daemon. +function getDockerStage(agent: Agent): FlowStage { + const label = m.cardStageDocker(); + switch (agent.status) { + case AgentStatus.Online: + return { + label, + value: agent.dockerVersion ?? m.notAvailableShort(), + state: "ok", + mono: true, + }; + case AgentStatus.Error: + return { + label, + value: m.cardDockerUnreachable(), + title: m.agentStatusErrorDescription(), + state: "error", + }; + default: + return { label, value: agent.dockerVersion ?? m.unknown(), state: "unknown", mono: true }; + } +} + +export function AgentCard({ agent, action }: { agent: Agent; action?: ReactNode }) { + const connected = agent.status !== AgentStatus.Offline; + + return ( + } + title={agent.name} + action={action} + stages={[ + { + label: m.cardStageHub(), + value: connected ? m.statusOnline() : m.statusOffline(), + state: connected ? "ok" : "error", + }, + getDockerStage(agent), + { + label: m.cardStageApplications(), + value: agent.appsCount ?? m.notAvailableShort(), + state: agent.appsCount ? "info" : "off", + }, + ]} + meta={} + time={agent.lastSeen ?? null} + /> + ); +} diff --git a/frontend/src/components/cards/application-card.tsx b/frontend/src/components/cards/application-card.tsx new file mode 100644 index 00000000..01bd3b61 --- /dev/null +++ b/frontend/src/components/cards/application-card.tsx @@ -0,0 +1,72 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowUpRight, Server } from "lucide-react"; +import { + getHealthStatusLabel, + getSyncStatusLabel, +} from "@/components/badges/application-status-badge"; +import { StaticLucideIcon } from "@/components/lucide-icon-picker"; +import { HealthStatus, SyncStatus, type ApplicationListItem } from "@/lib/applications"; +import { m } from "@/lib/paraglide/messages"; +import { ResourceCard, type FlowState } from "./resource-card"; + +const syncState: Record = { + [SyncStatus.Synced]: "ok", + [SyncStatus.OutOfSync]: "warning", + [SyncStatus.Syncing]: "progress", + [SyncStatus.Unknown]: "unknown", +}; + +const healthState: Record = { + [HealthStatus.Healthy]: "ok", + [HealthStatus.Unhealthy]: "warning", + [HealthStatus.Degraded]: "warning", + [HealthStatus.Stopped]: "error", + [HealthStatus.Unknown]: "unknown", +}; + +export function ApplicationCard({ app }: { app: ApplicationListItem }) { + return ( + + } + title={app.name} + subtitle={`${app.repositoryName} / ${app.branch}`} + monoSubtitle + action={ + + } + stages={[ + { + label: m.cardStageRevision(), + value: app.commit ? app.commit.slice(0, 7) : m.notAvailableShort(), + title: app.commit || undefined, + state: app.commit ? "info" : "unknown", + mono: true, + }, + { + label: m.cardStageSync(), + value: getSyncStatusLabel(app.syncStatus), + state: syncState[app.syncStatus], + }, + { + label: m.cardStageHealth(), + value: getHealthStatusLabel(app.healthStatus), + state: healthState[app.healthStatus], + }, + ]} + error={app.lastSyncError} + meta={ + <> + + {app.agentName} + + } + time={app.lastSyncedAt ?? null} + /> + + ); +} diff --git a/frontend/src/components/cards/notification-card.tsx b/frontend/src/components/cards/notification-card.tsx new file mode 100644 index 00000000..b0c324d6 --- /dev/null +++ b/frontend/src/components/cards/notification-card.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { Bell, WebhookIcon } from "lucide-react"; +import { getNotificationStatusLabel } from "@/components/badges/notification-status-badge"; +import { + getNotificationTypeIconPath, + type Notification, + type NotificationStatus, +} from "@/lib/notifications"; +import { m } from "@/lib/paraglide/messages"; +import { ResourceCard, type FlowState } from "./resource-card"; + +const deliveryState: Record = { + success: "ok", + healthy: "ok", + unhealthy: "warning", + error: "error", + unknown: "unknown", +}; + +export function NotificationCard({ + notification, + action, +}: { + notification: Notification; + action?: ReactNode; +}) { + return ( + + ) : ( + {m.notificationProviderAlt()} + ) + } + title={notification.name} + subtitle={notification.type.charAt(0).toUpperCase() + notification.type.slice(1)} + action={action} + stages={[ + { + label: m.cardStageApplications(), + value: notification.allApplications + ? m.allApplications() + : notification.applicationIds.length, + state: + notification.allApplications || notification.applicationIds.length ? "info" : "off", + }, + { + label: m.cardStageChannel(), + value: notification.enabled ? m.enabled() : m.disabled(), + state: notification.enabled ? "ok" : "off", + }, + { + label: m.cardStageDelivery(), + value: getNotificationStatusLabel(notification.status), + state: deliveryState[notification.status], + }, + ]} + meta={ + <> + + + {m.cardEventsCount({ count: notification.events.length })} + + + } + time={notification.updatedAt} + /> + ); +} diff --git a/frontend/src/components/cards/repository-card.tsx b/frontend/src/components/cards/repository-card.tsx new file mode 100644 index 00000000..f8b95075 --- /dev/null +++ b/frontend/src/components/cards/repository-card.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { getRepositorySyncStatusLabel } from "@/components/badges/repository-status-badge"; +import { getSyncTypeIcon, getSyncTypeLabel } from "@/components/badges/repository-sync-type-badge"; +import { m } from "@/lib/paraglide/messages"; +import { + getGitProviderIconClass, + getGitProviderIconPath, + type Repository, + type RepositorySyncStatus, +} from "@/lib/repositories"; +import { cn } from "@/lib/utils"; +import { ResourceCard, type FlowState } from "./resource-card"; + +const syncState: Record = { + success: "ok", + syncing: "progress", + failed: "error", + unknown: "unknown", +}; + +// Handles HTTP(S) as well as scp-like SSH URLs (git@host:org/repo.git). +function getRemoteHost(url: string): string { + try { + return new URL(url).host || url; + } catch { + return /^[^@]+@([^:/]+)/.exec(url)?.[1] ?? url; + } +} + +export function RepositoryCard({ + repository, + action, +}: { + repository: Repository; + action?: ReactNode; +}) { + return ( + + } + title={repository.name} + action={action} + stages={[ + { + label: m.cardStageRemote(), + value: getRemoteHost(repository.url), + title: repository.url, + state: "info", + mono: true, + }, + { + label: m.cardStageSync(), + value: getRepositorySyncStatusLabel(repository.syncStatus), + state: syncState[repository.syncStatus], + }, + { + label: m.cardStageApplications(), + value: repository.appCount ?? m.notAvailableShort(), + state: repository.appCount ? "info" : "off", + }, + ]} + error={repository.syncStatus === "failed" ? repository.lastSyncError : null} + meta={ + <> + {getSyncTypeIcon(repository.syncType, "size-3.5 shrink-0")} + {getSyncTypeLabel(repository.syncType)} + + } + time={repository.lastSyncedAt} + /> + ); +} diff --git a/frontend/src/components/cards/resource-card.tsx b/frontend/src/components/cards/resource-card.tsx new file mode 100644 index 00000000..12728509 --- /dev/null +++ b/frontend/src/components/cards/resource-card.tsx @@ -0,0 +1,186 @@ +import type { ReactNode } from "react"; +import { CircleAlert } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { m } from "@/lib/paraglide/messages"; +import { cn, formatRelativeTime } from "@/lib/utils"; + +export type FlowState = "ok" | "progress" | "warning" | "error" | "unknown" | "info" | "off"; + +export interface FlowStage { + label: string; + value: ReactNode; + state: FlowState; + title?: string; + mono?: boolean; +} + +const nodeClass: Record = { + ok: "border-emerald-500 bg-emerald-500", + progress: "border-blue-500 bg-blue-500/25 motion-safe:animate-pulse", + warning: "border-amber-500 bg-amber-500", + error: "border-red-500 bg-red-500 ring-4 ring-red-500/15", + unknown: "border-dashed border-zinc-400 dark:border-zinc-500", + info: "border-primary bg-card", + off: "border-zinc-400 bg-zinc-400/30 dark:border-zinc-500", +}; + +const segmentClass: Record = { + ok: "border-emerald-500/60", + progress: "", + warning: "border-amber-500/60", + error: "border-red-500/60", + unknown: "border-dashed border-zinc-400/60", + info: "border-foreground/15", + off: "border-dashed border-zinc-400/60", +}; + +const valueClass: Record = { + ok: "", + progress: "text-blue-600 dark:text-blue-400", + warning: "text-amber-600 dark:text-amber-400", + error: "text-red-600 dark:text-red-400", + unknown: "text-muted-foreground", + info: "", + off: "text-muted-foreground", +}; + +const brokenStates = new Set(["error", "unknown", "off"]); + +function FlowSegment({ state, interrupted }: { state: FlowState; interrupted: boolean }) { + if (interrupted) { + return ; + } + if (state === "progress") { + return ( + + ); + } + return ; +} + +/** + * Shows the stages a resource passes through (e.g. revision → sync → health). Each segment takes + * the state of the stage it leads to; once a stage is broken or unknown, everything after it is + * drawn as interrupted, so the point where the flow stops is visible at a glance. + */ +function ResourceFlow({ stages }: { stages: [FlowStage, FlowStage, FlowStage] }) { + const firstBroken = stages.findIndex((stage) => brokenStates.has(stage.state)); + + return ( +
+ +
+ {stages.map((stage, index) => ( +
+
+ {stage.label} +
+
+ {stage.value} +
+
+ ))} +
+
+ ); +} + +interface ResourceCardProps { + icon: ReactNode; + title: string; + subtitle?: ReactNode; + monoSubtitle?: boolean; + action?: ReactNode; + stages: [FlowStage, FlowStage, FlowStage]; + error?: string | null; + meta?: ReactNode; + time?: string | null; + className?: string; +} + +export function ResourceCard({ + icon, + title, + subtitle, + monoSubtitle = false, + action, + stages, + error, + meta, + time, + className, +}: ResourceCardProps) { + return ( + +
+
+ {icon} +
+
+

+ {title} +

+ {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
+ {action ?
{action}
: null} +
+ +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+ +
+
{meta}
+ {time !== undefined ? ( + + {time ? formatRelativeTime(time) : m.never()} + + ) : null} +
+
+ ); +} diff --git a/frontend/src/routes/_authenticated/agents/index.tsx b/frontend/src/routes/_authenticated/agents/index.tsx index 334a8c5d..eddad3e1 100644 --- a/frontend/src/routes/_authenticated/agents/index.tsx +++ b/frontend/src/routes/_authenticated/agents/index.tsx @@ -3,10 +3,10 @@ import { useFetch } from "@/lib/api"; import { createFileRoute } from "@tanstack/react-router"; import { m } from "@/lib/paraglide/messages"; import { useMemo, useState } from "react"; -import { AppWindow, Container, EllipsisVertical, Search, Tag, Trash2 } from "lucide-react"; +import { EllipsisVertical, Search, Trash2 } from "lucide-react"; import ConfirmationDialog from "@/components/dialogs/confirm-dialog"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { AgentCard } from "@/components/cards/agent-card"; import { DropdownMenu, DropdownMenuContent, @@ -20,13 +20,10 @@ import { toSearchableText } from "@/lib/utils"; import { toast } from "@/components/ui/toast"; import { AgentsDataTable } from "@/components/tables/agents/data-table"; import { columns } from "@/components/tables/agents/columns"; -import { AgentStatusBadge } from "@/components/badges/agent-status-badge"; -import { AgentVersionBadge } from "@/components/badges/agent-version-badge"; import OutdatedAgentsAlert from "@/components/alerts/outdated-agents-alert"; import { usePreferredLayout } from "@/lib/layout-preference"; import { LayoutToggleGroup } from "@/components/layout-toggle-group"; import RotateAgentTokenDialog from "@/components/dialogs/rotate-agent-token"; -import { StaticLucideIcon } from "@/components/lucide-icon-picker"; export const Route = createFileRoute("/_authenticated/agents/")({ component: RouteComponent, @@ -118,91 +115,41 @@ function RouteComponent() { ) : null}
{filteredAgents.map((agent) => ( - - - - - - - {m.cardActions()} - - } - > - - - {m.actions()} - - - handleDeleteCard(agent)} - title={m.deleteAgentCardTitle()} - description={m.deleteAgentCardDescription({ name: agent.name })} - triggerText={ - <> - - {m.delete()} - - } - asDropdownItem - /> - - - - - -
-
- -
-
- - {agent.name} - -
-
-
- -
- - - - -
-
-

- - {m.appsCount()} -

-

- {agent.appsCount ?? m.notAvailableShort()} -

-
-
-

- - {m.dockerVersion()} -

-

- {agent.dockerVersion ?? m.notAvailableShort()} -

-
-
-

- - {m.agentVersion()} -

-
- -
-
-
-
-
+ + + + {m.cardActions()} + + } + > + + + {m.actions()} + + + handleDeleteCard(agent)} + title={m.deleteAgentCardTitle()} + description={m.deleteAgentCardDescription({ name: agent.name })} + triggerText={ + <> + + {m.delete()} + + } + asDropdownItem + /> + + + + } + /> ))}
diff --git a/frontend/src/routes/_authenticated/applications/index.tsx b/frontend/src/routes/_authenticated/applications/index.tsx index 6278651a..da2bda64 100644 --- a/frontend/src/routes/_authenticated/applications/index.tsx +++ b/frontend/src/routes/_authenticated/applications/index.tsx @@ -1,20 +1,17 @@ import type { ApplicationListItem } from "@/lib/applications"; import type { Agent } from "@/lib/agents"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { z } from "zod"; -import { ArrowRight, GitBranch, GitCommit, Search, Server } from "lucide-react"; +import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; -import { ApplicationStatusBadge } from "@/components/badges/application-status-badge"; import { ApplicationsDataTable } from "@/components/tables/applications/data-table"; import { columns } from "@/components/tables/applications/columns"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ApplicationCard } from "@/components/cards/application-card"; import { useFetch } from "@/lib/api"; -import { Separator } from "@/components/ui/separator"; import { m } from "@/lib/paraglide/messages"; import { usePreferredLayout } from "@/lib/layout-preference"; import { LayoutToggleGroup } from "@/components/layout-toggle-group"; -import { StaticLucideIcon } from "@/components/lucide-icon-picker"; import UpsertApplicationDialog from "@/components/dialogs/upsert-application"; import { fleetCategories, @@ -188,59 +185,7 @@ function ApplicationsPage() { ) : null}
{filteredApps.map((app) => ( - - - - -
-
- -
-
-

- {app.name} -

-
-
-
- - - -
- -
- - -
- - - -
-
- - {app.agentName} -
-
- - - {app.repositoryName} @ {app.branch} - -
-
-
- - {app.commit.slice(0, 7)} -
- - {app.lastSyncedAt - ? new Date(app.lastSyncedAt).toLocaleString() - : m.never()} - -
-
-
-
- + ))}
diff --git a/frontend/src/routes/_authenticated/notifications/index.tsx b/frontend/src/routes/_authenticated/notifications/index.tsx index 318c0b7d..a466ef10 100644 --- a/frontend/src/routes/_authenticated/notifications/index.tsx +++ b/frontend/src/routes/_authenticated/notifications/index.tsx @@ -1,9 +1,8 @@ import { useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { EllipsisVertical, Search, Send, Trash2, WebhookIcon } from "lucide-react"; +import { EllipsisVertical, Search, Send, Trash2 } from "lucide-react"; import { toast } from "@/components/ui/toast"; -import { NotificationStatusBadge } from "@/components/badges/notification-status-badge"; import ConfirmationDialog from "@/components/dialogs/confirm-dialog"; import CreateNotificationDialog from "@/components/dialogs/create-notification"; import UpdateNotificationDialog from "@/components/dialogs/update-notification"; @@ -11,7 +10,7 @@ import { LayoutToggleGroup } from "@/components/layout-toggle-group"; import { columns } from "@/components/tables/notifications/columns"; import { NotificationsDataTable } from "@/components/tables/notifications/data-table"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { NotificationCard } from "@/components/cards/notification-card"; import { DropdownMenu, DropdownMenuContent, @@ -24,12 +23,7 @@ import { import { Input } from "@/components/ui/input"; import { useFetch } from "@/lib/api"; import { usePreferredLayout } from "@/lib/layout-preference"; -import { - deleteNotification, - getNotificationTypeIconPath, - type Notification, - testNotification, -} from "@/lib/notifications"; +import { deleteNotification, type Notification, testNotification } from "@/lib/notifications"; import { m } from "@/lib/paraglide/messages"; import { toSearchableText } from "@/lib/utils"; @@ -125,107 +119,53 @@ function NotificationsPage() {
{filteredNotifications.map((notification) => ( - - - - - - - {m.cardActions()} - - } - > - - - {m.actions()} - - { - void handleTest(notification); - }} - > - - {m.sendTest()} - - - { - void handleDelete(notification); - }} - title={m.deleteNotificationTitle()} - description={m.deleteNotificationDescription({ - name: notification.name, - })} - triggerText={ - <> - - {m.delete()} - - } - asDropdownItem - /> - - - - - -
-
- {notification.type === "webhook" ? ( - - ) : ( - {m.notificationProviderAlt()} + + + {m.cardActions()} + + } + > + + + {m.actions()} + + { + void handleTest(notification); + }} + > + + {m.sendTest()} + + + { + void handleDelete(notification); + }} + title={m.deleteNotificationTitle()} + description={m.deleteNotificationDescription({ + name: notification.name, + })} + triggerText={ + <> + + {m.delete()} + + } + asDropdownItem /> - )} -
-
- - {notification.name} - -
-
-
- -
- - - - -
-
-

{m.columnType()}

-

- {notification.type.charAt(0).toUpperCase() + notification.type.slice(1)} -

-
-
-

{m.appsCount()}

-

- {notification.allApplications - ? m.allApplications() - : notification.applicationIds.length} -

-
-
- -
- - {notification.enabled ? m.enabled() : m.disabled()} - - - {new Date(notification.updatedAt).toLocaleString()} - -
-
-
+ + + + } + /> ))}
diff --git a/frontend/src/routes/_authenticated/repositories/index.tsx b/frontend/src/routes/_authenticated/repositories/index.tsx index 3527e444..d8dd489a 100644 --- a/frontend/src/routes/_authenticated/repositories/index.tsx +++ b/frontend/src/routes/_authenticated/repositories/index.tsx @@ -1,22 +1,16 @@ import { columns } from "@/components/tables/repositories/columns"; import { RepositoryDataTable } from "@/components/tables/repositories/data-table"; import CreateRepositoryDialog from "@/components/dialogs/create-repository"; -import { - deleteRepository, - syncRepository, - getGitProviderIconClass, - getGitProviderIconPath, - type Repository, -} from "@/lib/repositories"; +import { deleteRepository, syncRepository, type Repository } from "@/lib/repositories"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useFetch } from "@/lib/api"; import { m } from "@/lib/paraglide/messages"; import { useMemo, useState } from "react"; -import { AppWindow, EllipsisVertical, RefreshCw, Search, Settings, Trash2 } from "lucide-react"; +import { EllipsisVertical, RefreshCw, Search, Settings, Trash2 } from "lucide-react"; import ConfirmationDialog from "@/components/dialogs/confirm-dialog"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { RepositoryCard } from "@/components/cards/repository-card"; import { DropdownMenu, DropdownMenuContent, @@ -29,8 +23,6 @@ import { import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/toast"; import { toSearchableText } from "@/lib/utils"; -import { RepositoryStatusBadge } from "@/components/badges/repository-status-badge"; -import { RepositorySyncTypeBadge } from "@/components/badges/repository-sync-type-badge"; import { usePreferredLayout } from "@/lib/layout-preference"; import { LayoutToggleGroup } from "@/components/layout-toggle-group"; @@ -131,102 +123,56 @@ function RepositoriesPage() { ) : null}
{filteredRepositories.map((repository) => ( - - - - - - - {m.cardActions()} - - } - > - - - {m.actions()} - handleSyncRepo(repository)}> - - {m.sync()} - - - - - {m.settings()} - - } - /> - - handleDeleteRepo(repository)} - title={m.deleteRepositoryTitle()} - description={m.deleteRepositoryDescription({ name: repository.name })} - triggerText={ - <> - - {m.delete()} - - } - asDropdownItem - /> - - - - - -
-
- {m.gitProviderAlt()} -
-
- - {repository.name} - -
-
-
- -
- - - - -
-
-

- - {m.appsCount()} -

-

- {repository.appCount ?? m.notAvailableShort()} -

-
-
- -
- - - - {repository.lastSyncedAt - ? new Date(repository.lastSyncedAt).toLocaleString() - : m.never()} - -
-
-
+ repository={repository} + action={ + + + + {m.cardActions()} + + } + > + + + {m.actions()} + handleSyncRepo(repository)}> + + {m.sync()} + + + + + {m.settings()} + + } + /> + + handleDeleteRepo(repository)} + title={m.deleteRepositoryTitle()} + description={m.deleteRepositoryDescription({ name: repository.name })} + triggerText={ + <> + + {m.delete()} + + } + asDropdownItem + /> + + + + } + /> ))}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 90910a53..43f67c27 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -120,6 +120,13 @@ @theme { --animate-rocket-launch: rocket-launch 1.4s infinite; + --animate-flow-dash: flow-dash 0.8s linear infinite; + + @keyframes flow-dash { + to { + background-position-x: 8px; + } + } /* * Lucide's rocket points to the top right: it accelerates out of its (clipped) box towards the From 001ef9a7cb1183e4fdf3ebddaca1937ca80a1715 Mon Sep 17 00:00:00 2001 From: alex289 Date: Thu, 29 Oct 2026 20:09:09 +0100 Subject: [PATCH 2/4] feat(ui): add contextual content to agent, repository, and notification The redesigned resource cards showed only flow-stage summaries, which hid useful context like which applications an agent or repository was tied to, and the per-resource subscription detail on notification cards. - Refactor ResourceCard to accept arbitrary children plus a footer, replacing the fixed stages/meta/time props (resource-card.tsx) - AgentCard: show connection status and a linked list of deployed applications - RepositoryCard: show sync status detail, trigger interval, and applications using the repository, plus an inline manual sync action - NotificationCard: show per-resource subscription rows with event tooltips and an inline enable/disable switch - Expose repositoryId on application list items (backend + frontend) so cards can group applications by repository - Add groupBy utility and wire agents/repositories routes to group applications by agent/repository for the new card content - Update en/de translation messages for the new card strings --- backend/internal/hub/routes/applications.go | 2 + .../internal/hub/routes/applications_test.go | 3 + frontend/messages/de.json | 17 +- frontend/messages/en.json | 17 +- frontend/src/components/cards/agent-card.tsx | 177 +++++++++++---- .../src/components/cards/application-card.tsx | 63 +++--- .../components/cards/notification-card.tsx | 210 +++++++++++++++--- .../src/components/cards/repository-card.tsx | 161 ++++++++++---- .../src/components/cards/resource-card.tsx | 63 +++--- .../notification-subscription-fields.tsx | 4 +- frontend/src/lib/applications.ts | 1 + frontend/src/lib/utils.ts | 14 ++ .../routes/_authenticated/agents/index.tsx | 9 +- .../_authenticated/repositories/index.tsx | 12 +- 14 files changed, 562 insertions(+), 191 deletions(-) diff --git a/backend/internal/hub/routes/applications.go b/backend/internal/hub/routes/applications.go index 106540fb..cfdb9e90 100644 --- a/backend/internal/hub/routes/applications.go +++ b/backend/internal/hub/routes/applications.go @@ -78,6 +78,7 @@ type applicationListResponse struct { LastSyncError *string `json:"lastSyncError,omitempty"` AgentId string `json:"agentId"` AgentName string `json:"agentName"` + RepositoryId string `json:"repositoryId"` RepositoryName string `json:"repositoryName"` } @@ -541,6 +542,7 @@ func toApplicationListResponse(app *models.Application) applicationListResponse LastSyncedAt: formatTimestamp(app.LastSyncedAt), LastSyncError: normalizeSyncError(app.LastSyncError), Path: app.Path, + RepositoryId: app.RepositoryId, RepositoryName: app.Repository.Name, AgentId: app.AgentId, AgentName: app.Agent.Name.String(), diff --git a/backend/internal/hub/routes/applications_test.go b/backend/internal/hub/routes/applications_test.go index e91cf8a6..9e0fcf26 100644 --- a/backend/internal/hub/routes/applications_test.go +++ b/backend/internal/hub/routes/applications_test.go @@ -258,6 +258,9 @@ func TestListApplicationsHandler_ReturnsSummaryFields(t *testing.T) { if item["name"] != "Summary App" { t.Errorf("expected name %q, got %v", "Summary App", item["name"]) } + if item["repositoryId"] != repo.Id { + t.Errorf("expected repositoryId %q, got %v", repo.Id, item["repositoryId"]) + } if item["repositoryName"] != repo.Name { t.Errorf("expected repositoryName %q, got %v", repo.Name, item["repositoryName"]) } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index d329e389..142d45c4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -89,14 +89,15 @@ "cardStageRevision": "Revision", "cardStageSync": "Sync", "cardStageHealth": "Zustand", - "cardStageRemote": "Remote", - "cardStageApplications": "Apps", - "cardStageHub": "Hub", - "cardStageDocker": "Docker", - "cardStageChannel": "Kanal", - "cardStageDelivery": "Letzte Zustellung", - "cardDockerUnreachable": "Nicht erreichbar", - "cardEventsCount": "Ereignisse: {count}", + "cardAgentNoApplications": "Noch keine Anwendungen bereitgestellt", + "cardPollingEvery": "Polling alle {interval}", + "cardRepositoryUnused": "Von keiner Anwendung verwendet", + "cardScopeApplications": "Anwendungen: {count}", + "cardScopeAgents": "Agenten: {count}", + "cardScopeRepositories": "Repositories: {count}", + "cardSubscribedEvents": "{count} von {total} Ereignissen", + "cardLastDelivery": "Letzte Zustellung", + "dockerVersion": "Docker-Version", "deleteAgentCardTitle": "Agentenkarte löschen?", "deleteAgentCardDescription": "Dadurch wird \"{name}\" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "deleteRepositoryTitle": "Repository löschen?", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 08381060..6b89b55d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -89,14 +89,15 @@ "cardStageRevision": "Revision", "cardStageSync": "Sync", "cardStageHealth": "Health", - "cardStageRemote": "Remote", - "cardStageApplications": "Apps", - "cardStageHub": "Hub", - "cardStageDocker": "Docker", - "cardStageChannel": "Channel", - "cardStageDelivery": "Last delivery", - "cardDockerUnreachable": "Unreachable", - "cardEventsCount": "Events: {count}", + "cardAgentNoApplications": "No applications deployed yet", + "cardPollingEvery": "Polling every {interval}", + "cardRepositoryUnused": "Not used by any application", + "cardScopeApplications": "Applications: {count}", + "cardScopeAgents": "Agents: {count}", + "cardScopeRepositories": "Repositories: {count}", + "cardSubscribedEvents": "{count} of {total} events", + "cardLastDelivery": "Last delivery", + "dockerVersion": "Docker version", "deleteAgentCardTitle": "Delete agent card?", "deleteAgentCardDescription": "This will permanently delete \"{name}\". This action cannot be undone.", "deleteRepositoryTitle": "Delete repository?", diff --git a/frontend/src/components/cards/agent-card.tsx b/frontend/src/components/cards/agent-card.tsx index 6a824ca6..4acf4b57 100644 --- a/frontend/src/components/cards/agent-card.tsx +++ b/frontend/src/components/cards/agent-card.tsx @@ -1,56 +1,155 @@ import type { ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { Container } from "lucide-react"; import { AgentVersionBadge } from "@/components/badges/agent-version-badge"; import { StaticLucideIcon } from "@/components/lucide-icon-picker"; +import { Skeleton } from "@/components/ui/skeleton"; import { AgentStatus, type Agent } from "@/lib/agents"; +import type { ApplicationListItem } from "@/lib/applications"; +import { fleetCategoryConfig, getFleetCategory } from "@/lib/fleet"; import { m } from "@/lib/paraglide/messages"; -import { ResourceCard, type FlowStage } from "./resource-card"; +import { cn, formatRelativeTime } from "@/lib/utils"; +import { ResourceCard } from "./resource-card"; -// An agent in the error state is connected to the hub, but cannot reach the Docker daemon. -function getDockerStage(agent: Agent): FlowStage { - const label = m.cardStageDocker(); - switch (agent.status) { - case AgentStatus.Online: - return { - label, - value: agent.dockerVersion ?? m.notAvailableShort(), - state: "ok", - mono: true, - }; - case AgentStatus.Error: - return { - label, - value: m.cardDockerUnreachable(), - title: m.agentStatusErrorDescription(), - state: "error", - }; - default: - return { label, value: agent.dockerVersion ?? m.unknown(), state: "unknown", mono: true }; +const maxListedApplications = 3; + +function ConnectionStatus({ agent }: { agent: Agent }) { + if (agent.status === AgentStatus.Offline) { + return ( + + + {m.statusOffline()} + + ·{" "} + {agent.lastSeen + ? m.applicationsAgentsLastSeen({ time: formatRelativeTime(agent.lastSeen) }) + : m.applicationsAgentsNeverSeen()} + + + ); } + + // An agent in the error state is connected to the hub, but cannot reach the Docker daemon. + const dockerUnreachable = agent.status === AgentStatus.Error; + return ( + + + {dockerUnreachable ? null : ( + + )} + + + {m.statusOnline()} + {dockerUnreachable ? ( + + · {m.applicationsAgentsDockerUnreachable()} + + ) : null} + + ); } -export function AgentCard({ agent, action }: { agent: Agent; action?: ReactNode }) { - const connected = agent.status !== AgentStatus.Offline; +function ApplicationList({ + agentId, + apps, +}: { + agentId: string; + apps: ApplicationListItem[] | undefined; +}) { + if (!apps) { + return ( +
+ + +
+ ); + } + + if (apps.length === 0) { + return ( +

+ {m.cardAgentNoApplications()} +

+ ); + } + + return ( +
    + {apps.slice(0, maxListedApplications).map((app) => { + const category = fleetCategoryConfig[getFleetCategory(app)]; + return ( +
  • + + + {app.name} + + {category.label()} + + + +
  • + ); + })} + {apps.length > maxListedApplications ? ( +
  • + + {m.applicationsAttentionMore({ count: apps.length - maxListedApplications })} + +
  • + ) : null} +
+ ); +} +export function AgentCard({ + agent, + apps, + action, +}: { + agent: Agent; + apps: ApplicationListItem[] | undefined; + action?: ReactNode; +}) { return ( } title={agent.name} + subtitle={} action={action} - stages={[ - { - label: m.cardStageHub(), - value: connected ? m.statusOnline() : m.statusOffline(), - state: connected ? "ok" : "error", - }, - getDockerStage(agent), - { - label: m.cardStageApplications(), - value: agent.appsCount ?? m.notAvailableShort(), - state: agent.appsCount ? "info" : "off", - }, - ]} - meta={} - time={agent.lastSeen ?? null} - /> + footer={ + <> + + + + Docker{" "} + {agent.dockerVersion ?? m.notAvailableShort()} + + + + + } + > +
+ {m.navApplications()} + {apps ? {apps.length} : null} +
+ +
); } diff --git a/frontend/src/components/cards/application-card.tsx b/frontend/src/components/cards/application-card.tsx index 01bd3b61..b11da2bd 100644 --- a/frontend/src/components/cards/application-card.tsx +++ b/frontend/src/components/cards/application-card.tsx @@ -7,7 +7,13 @@ import { import { StaticLucideIcon } from "@/components/lucide-icon-picker"; import { HealthStatus, SyncStatus, type ApplicationListItem } from "@/lib/applications"; import { m } from "@/lib/paraglide/messages"; -import { ResourceCard, type FlowState } from "./resource-card"; +import { + CardError, + RelativeTime, + ResourceCard, + ResourceFlow, + type FlowState, +} from "./resource-card"; const syncState: Record = { [SyncStatus.Synced]: "ok", @@ -39,34 +45,39 @@ export function ApplicationCard({ app }: { app: ApplicationListItem }) { action={ } - stages={[ - { - label: m.cardStageRevision(), - value: app.commit ? app.commit.slice(0, 7) : m.notAvailableShort(), - title: app.commit || undefined, - state: app.commit ? "info" : "unknown", - mono: true, - }, - { - label: m.cardStageSync(), - value: getSyncStatusLabel(app.syncStatus), - state: syncState[app.syncStatus], - }, - { - label: m.cardStageHealth(), - value: getHealthStatusLabel(app.healthStatus), - state: healthState[app.healthStatus], - }, - ]} - error={app.lastSyncError} - meta={ + footer={ <> - - {app.agentName} + + + {app.agentName} + + } - time={app.lastSyncedAt ?? null} - /> + > + + {app.lastSyncError ? : null} +
); } diff --git a/frontend/src/components/cards/notification-card.tsx b/frontend/src/components/cards/notification-card.tsx index b0c324d6..26fb63a3 100644 --- a/frontend/src/components/cards/notification-card.tsx +++ b/frontend/src/components/cards/notification-card.tsx @@ -1,22 +1,171 @@ -import type { ReactNode } from "react"; -import { Bell, WebhookIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { mutate } from "swr"; +import { AppWindow, Check, GitBranch, Minus, Server, WebhookIcon } from "lucide-react"; import { getNotificationStatusLabel } from "@/components/badges/notification-status-badge"; import { + notificationEventLabels, + resourceConfigs, +} from "@/components/dialogs/notification-subscription-fields"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/components/ui/toast"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { API_BASE } from "@/lib/api"; +import { + getNotificationResourceEvents, getNotificationTypeIconPath, + normalizeNotificationSubscription, + notificationResources, + updateNotification, type Notification, + type NotificationResource, type NotificationStatus, } from "@/lib/notifications"; import { m } from "@/lib/paraglide/messages"; -import { ResourceCard, type FlowState } from "./resource-card"; +import { cn } from "@/lib/utils"; +import { ResourceCard } from "./resource-card"; + +const resourceIcons: Record = { + applications: AppWindow, + agents: Server, + repositories: GitBranch, +}; -const deliveryState: Record = { - success: "ok", - healthy: "ok", - unhealthy: "warning", - error: "error", - unknown: "unknown", +const selectedScopeLabels: Record string> = { + applications: (count) => m.cardScopeApplications({ count }), + agents: (count) => m.cardScopeAgents({ count }), + repositories: (count) => m.cardScopeRepositories({ count }), }; +const deliveryDotClass: Record = { + success: "bg-emerald-500", + healthy: "bg-emerald-500", + unhealthy: "bg-amber-500", + error: "bg-red-500", + unknown: "bg-zinc-400", +}; + +// One dot per event of the resource, filled when the notification is subscribed to it. +function SubscriptionRow({ + notification, + resource, +}: { + notification: Notification; + resource: NotificationResource; +}) { + const config = resourceConfigs[resource]; + const Icon = resourceIcons[resource]; + const events = getNotificationResourceEvents(resource); + const subscribed = events.filter((event) => notification.events.includes(event)); + const scope = notification[config.allKey] + ? config.all() + : selectedScopeLabels[resource](notification[config.idsKey].length); + + return ( +
  • + + + {scope} + + + + } + > + + {subscribed.length}/{events.length} + + {events.map((event) => ( + + ))} + + +
      + {events.map((event) => ( +
    • + {subscribed.includes(event) ? ( + + ) : ( + + )} + {notificationEventLabels[event]()} +
    • + ))} +
    +
    +
    +
  • + ); +} + +function EnabledSwitch({ notification }: { notification: Notification }) { + const [pending, setPending] = useState(null); + const enabled = pending ?? notification.enabled; + + const handleChange = async (checked: boolean) => { + setPending(checked); + try { + await updateNotification(notification.id, { + enabled: checked, + ...normalizeNotificationSubscription({ + events: notification.events, + allApplications: notification.allApplications, + applicationIds: notification.applicationIds, + allAgents: notification.allAgents, + agentIds: notification.agentIds, + allRepositories: notification.allRepositories, + repositoryIds: notification.repositoryIds, + }), + }); + await mutate(`${API_BASE}/notifications`); + toast.add({ type: "success", title: m.notificationUpdated() }); + } catch (err) { + toast.add({ + type: "error", + title: err instanceof Error ? err.message : m.failedSaveNotification(), + }); + } finally { + setPending(null); + } + }; + + return ( + + ); +} + export function NotificationCard({ notification, action, @@ -40,35 +189,26 @@ export function NotificationCard({ title={notification.name} subtitle={notification.type.charAt(0).toUpperCase() + notification.type.slice(1)} action={action} - stages={[ - { - label: m.cardStageApplications(), - value: notification.allApplications - ? m.allApplications() - : notification.applicationIds.length, - state: - notification.allApplications || notification.applicationIds.length ? "info" : "off", - }, - { - label: m.cardStageChannel(), - value: notification.enabled ? m.enabled() : m.disabled(), - state: notification.enabled ? "ok" : "off", - }, - { - label: m.cardStageDelivery(), - value: getNotificationStatusLabel(notification.status), - state: deliveryState[notification.status], - }, - ]} - meta={ + footer={ <> - - - {m.cardEventsCount({ count: notification.events.length })} + + + {m.cardLastDelivery()} + + {getNotificationStatusLabel(notification.status)} + + } - time={notification.updatedAt} - /> + > +
      + {notificationResources.map((resource) => ( + + ))} +
    +
    ); } diff --git a/frontend/src/components/cards/repository-card.tsx b/frontend/src/components/cards/repository-card.tsx index f8b95075..51af382d 100644 --- a/frontend/src/components/cards/repository-card.tsx +++ b/frontend/src/components/cards/repository-card.tsx @@ -1,6 +1,11 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { AppWindow, CircleCheck, CircleDashed, CircleX, RefreshCw } from "lucide-react"; import { getRepositorySyncStatusLabel } from "@/components/badges/repository-status-badge"; import { getSyncTypeIcon, getSyncTypeLabel } from "@/components/badges/repository-sync-type-badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { ApplicationListItem } from "@/lib/applications"; import { m } from "@/lib/paraglide/messages"; import { getGitProviderIconClass, @@ -9,31 +14,89 @@ import { type RepositorySyncStatus, } from "@/lib/repositories"; import { cn } from "@/lib/utils"; -import { ResourceCard, type FlowState } from "./resource-card"; +import { RelativeTime, ResourceCard } from "./resource-card"; -const syncState: Record = { - success: "ok", - syncing: "progress", - failed: "error", - unknown: "unknown", +const maxListedApplications = 2; + +const syncStatusIcon: Record = { + success: , + failed: , + syncing: ( + + ), + unknown: , }; -// Handles HTTP(S) as well as scp-like SSH URLs (git@host:org/repo.git). -function getRemoteHost(url: string): string { - try { - return new URL(url).host || url; - } catch { - return /^[^@]+@([^:/]+)/.exec(url)?.[1] ?? url; +function formatInterval(seconds: number): string { + if (seconds % 3600 === 0) { + return `${seconds / 3600}h`; + } + if (seconds % 60 === 0) { + return `${seconds / 60}m`; + } + return `${seconds}s`; +} + +function getTriggerLabel(repository: Repository): string { + if (repository.syncType === "polling" && repository.pollingIntervalSeconds) { + return m.cardPollingEvery({ interval: formatInterval(repository.pollingIntervalSeconds) }); } + return getSyncTypeLabel(repository.syncType); +} + +function UsedBy({ apps }: { apps: ApplicationListItem[] | undefined }) { + if (!apps) { + return ; + } + if (apps.length === 0) { + return {m.cardRepositoryUnused()}; + } + + return ( + + + {apps.slice(0, maxListedApplications).map((app) => ( + + {app.name} + + ))} + {apps.length > maxListedApplications ? ( + + {m.applicationsAttentionMore({ count: apps.length - maxListedApplications })} + + ) : null} + + ); } export function RepositoryCard({ repository, + apps, + onSync, action, }: { repository: Repository; + apps: ApplicationListItem[] | undefined; + onSync: () => Promise; action?: ReactNode; }) { + const [triggering, setTriggering] = useState(false); + const failed = repository.syncStatus === "failed"; + + const handleSync = async () => { + setTriggering(true); + try { + await onSync(); + } finally { + setTriggering(false); + } + }; + return ( } title={repository.name} + subtitle={{repository.url.replace(/^[a-z]+:\/\//, "")}} + monoSubtitle action={action} - stages={[ - { - label: m.cardStageRemote(), - value: getRemoteHost(repository.url), - title: repository.url, - state: "info", - mono: true, - }, - { - label: m.cardStageSync(), - value: getRepositorySyncStatusLabel(repository.syncStatus), - state: syncState[repository.syncStatus], - }, - { - label: m.cardStageApplications(), - value: repository.appCount ?? m.notAvailableShort(), - state: repository.appCount ? "info" : "off", - }, - ]} - error={repository.syncStatus === "failed" ? repository.lastSyncError : null} - meta={ - <> + footer={} + > +
    +
    +
    + {syncStatusIcon[repository.syncStatus]} + + {getRepositorySyncStatusLabel(repository.syncStatus)} + + +
    + {failed && repository.lastSyncError ? ( +

    + {repository.lastSyncError} +

    + ) : null} +
    +
    {getSyncTypeIcon(repository.syncType, "size-3.5 shrink-0")} - {getSyncTypeLabel(repository.syncType)} - - } - time={repository.lastSyncedAt} - /> + {getTriggerLabel(repository)} + +
    +
    +
    ); } diff --git a/frontend/src/components/cards/resource-card.tsx b/frontend/src/components/cards/resource-card.tsx index 12728509..63adb382 100644 --- a/frontend/src/components/cards/resource-card.tsx +++ b/frontend/src/components/cards/resource-card.tsx @@ -63,7 +63,7 @@ function FlowSegment({ state, interrupted }: { state: FlowState; interrupted: bo * the state of the stage it leads to; once a stage is broken or unknown, everything after it is * drawn as interrupted, so the point where the flow stops is visible at a glance. */ -function ResourceFlow({ stages }: { stages: [FlowStage, FlowStage, FlowStage] }) { +export function ResourceFlow({ stages }: { stages: [FlowStage, FlowStage, FlowStage] }) { const firstBroken = stages.findIndex((stage) => brokenStates.has(stage.state)); return ( @@ -111,29 +111,49 @@ function ResourceFlow({ stages }: { stages: [FlowStage, FlowStage, FlowStage] }) ); } +export function RelativeTime({ value, className }: { value?: string | null; className?: string }) { + return ( + + {value ? formatRelativeTime(value) : m.never()} + + ); +} + +export function CardError({ message, className }: { message: string; className?: string }) { + return ( +

    + + {message} +

    + ); +} + interface ResourceCardProps { icon: ReactNode; title: string; subtitle?: ReactNode; monoSubtitle?: boolean; action?: ReactNode; - stages: [FlowStage, FlowStage, FlowStage]; - error?: string | null; - meta?: ReactNode; - time?: string | null; + children: ReactNode; + footer: ReactNode; className?: string; } +/** Shared shell of the resource cards: identity header, resource specific body and a footer bar. */ export function ResourceCard({ icon, title, subtitle, monoSubtitle = false, action, - stages, - error, - meta, - time, + children, + footer, className, }: ResourceCardProps) { return ( @@ -157,29 +177,10 @@ export function ResourceCard({ {action ?
    {action}
    : null} -
    - - {error ? ( -

    - - {error} -

    - ) : null} -
    +
    {children}
    -
    -
    {meta}
    - {time !== undefined ? ( - - {time ? formatRelativeTime(time) : m.never()} - - ) : null} +
    + {footer}
    ); diff --git a/frontend/src/components/dialogs/notification-subscription-fields.tsx b/frontend/src/components/dialogs/notification-subscription-fields.tsx index c364cff6..e4022344 100644 --- a/frontend/src/components/dialogs/notification-subscription-fields.tsx +++ b/frontend/src/components/dialogs/notification-subscription-fields.tsx @@ -42,7 +42,7 @@ export const notificationSubscriptionSchema = z.object({ repositoryIds: z.array(z.string()), }); -const notificationEventLabels: Record string> = { +export const notificationEventLabels: Record string> = { "application.deployment.succeeded": () => m.notificationEventDeploymentSucceeded(), "application.deployment.failed": () => m.notificationEventDeploymentFailed(), "application.image_update.succeeded": () => m.notificationEventImageUpdateSucceeded(), @@ -66,7 +66,7 @@ interface ResourceConfig { empty: () => string; } -const resourceConfigs: Record = { +export const resourceConfigs: Record = { applications: { allKey: "allApplications", idsKey: "applicationIds", diff --git a/frontend/src/lib/applications.ts b/frontend/src/lib/applications.ts index d1d82b7b..32f1291f 100644 --- a/frontend/src/lib/applications.ts +++ b/frontend/src/lib/applications.ts @@ -38,6 +38,7 @@ export interface ApplicationListItem { icon: string; syncStatus: SyncStatus; healthStatus: HealthStatus; + repositoryId: string; repositoryName: string; agentId: string; agentName: string; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 298394c3..110cfb64 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -86,3 +86,17 @@ export function formatDuration(ms: number): string { } export const isApple = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + +export function groupBy(items: T[], getKey: (item: T) => K): Map { + const groups = new Map(); + for (const item of items) { + const key = getKey(item); + const group = groups.get(key); + if (group) { + group.push(item); + } else { + groups.set(key, [item]); + } + } + return groups; +} diff --git a/frontend/src/routes/_authenticated/agents/index.tsx b/frontend/src/routes/_authenticated/agents/index.tsx index eddad3e1..6b21574f 100644 --- a/frontend/src/routes/_authenticated/agents/index.tsx +++ b/frontend/src/routes/_authenticated/agents/index.tsx @@ -16,7 +16,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { deleteAgent, type Agent } from "@/lib/agents"; -import { toSearchableText } from "@/lib/utils"; +import type { ApplicationListItem } from "@/lib/applications"; +import { groupBy, toSearchableText } from "@/lib/utils"; import { toast } from "@/components/ui/toast"; import { AgentsDataTable } from "@/components/tables/agents/data-table"; import { columns } from "@/components/tables/agents/columns"; @@ -38,6 +39,11 @@ export const Route = createFileRoute("/_authenticated/agents/")({ function RouteComponent() { const { data, isLoading } = useFetch("/agents"); + const { data: applications } = useFetch("/applications"); + const applicationsByAgent = useMemo( + () => (applications ? groupBy(applications, (app) => app.agentId) : undefined), + [applications], + ); const { preferredLayout: viewMode, setPreferredLayout: setViewMode } = usePreferredLayout(); const [searchQuery, setSearchQuery] = useState(""); @@ -118,6 +124,7 @@ function RouteComponent() { ("/repositories"); + const { data: applications } = useFetch("/applications"); + const applicationsByRepository = useMemo( + () => (applications ? groupBy(applications, (app) => app.repositoryId) : undefined), + [applications], + ); const [searchQuery, setSearchQuery] = useState(""); const { preferredLayout: viewMode, setPreferredLayout: setViewMode } = usePreferredLayout(); @@ -126,6 +132,10 @@ function RepositoriesPage() { handleSyncRepo(repository)} action={ Date: Fri, 30 Oct 2026 12:12:23 +0100 Subject: [PATCH 3/4] test(e2e): update repository card polling interval assertion The e2e test expected the repository card to show "Polling" but the card now displays the actual interval. Update the assertion to match the new label format. --- e2e/tests/repositories.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/repositories.test.ts b/e2e/tests/repositories.test.ts index 516e1faf..4d036d1b 100644 --- a/e2e/tests/repositories.test.ts +++ b/e2e/tests/repositories.test.ts @@ -464,7 +464,7 @@ test.describe("Live updates", () => { syncType: "polling", pollingIntervalSeconds: 3600, }); - await expect(card.getByText("Polling", { exact: true })).toBeVisible(); + await expect(card.getByText("Polling every 1h", { exact: true })).toBeVisible(); await api(page, "DELETE", `/repositories/${repo.id}`); await expect(card).toHaveCount(0); From e086b3eb21248e78bdf7c996e7b37148043bcb26 Mon Sep 17 00:00:00 2001 From: alex289 Date: Sat, 31 Oct 2026 15:53:24 +0100 Subject: [PATCH 4/4] feat(notifications): add endpoint to toggle enabled without overwriting Toggling a notification's enabled switch previously sent a full update request, which could overwrite a concurrent edit to the subscription (events, targets, etc.). - Add PUT /notifications/:id/enabled backend endpoint and handler that only updates the enabled flag - Switch the frontend enabled toggle to use the new endpoint instead of a full update - Extract formatInterval into lib/utils and share it between repository-card and its dependents - Sort agent card's listed applications by fleet category so apps needing attention show first - Minor card polish: application card subtitle separator, repository sync button variant --- backend/internal/hub/handlers.go | 1 + backend/internal/hub/routes/notifications.go | 42 ++++++++++ .../internal/hub/routes/notifications_test.go | 76 +++++++++++++++++++ frontend/src/components/cards/agent-card.tsx | 16 +++- .../src/components/cards/application-card.tsx | 2 +- .../components/cards/notification-card.tsx | 16 +--- .../src/components/cards/repository-card.tsx | 16 +--- frontend/src/lib/notifications.ts | 4 + frontend/src/lib/utils.ts | 11 +++ 9 files changed, 153 insertions(+), 31 deletions(-) diff --git a/backend/internal/hub/handlers.go b/backend/internal/hub/handlers.go index f42eb16a..59a20014 100644 --- a/backend/internal/hub/handlers.go +++ b/backend/internal/hub/handlers.go @@ -98,6 +98,7 @@ func RegisterRoutes(router *gin.Engine, cfg Config) error { protected.POST("/notifications", routes.CreateNotificationHandler) protected.GET("/notifications", routes.ListNotificationsHandler) protected.PUT("/notifications/:id", routes.UpdateNotificationHandler) + protected.PUT("/notifications/:id/enabled", routes.SetNotificationEnabledHandler) protected.DELETE("/notifications/:id", routes.DeleteNotificationHandler) protected.POST("/notifications/:id/test", routes.TestNotificationHandler) diff --git a/backend/internal/hub/routes/notifications.go b/backend/internal/hub/routes/notifications.go index 43e98326..08941c44 100644 --- a/backend/internal/hub/routes/notifications.go +++ b/backend/internal/hub/routes/notifications.go @@ -39,6 +39,10 @@ type createNotificationRequest struct { RepositoryIds []string `json:"repositoryIds"` } +type setNotificationEnabledRequest struct { + Enabled *bool `json:"enabled"` +} + type updateNotificationRequest struct { Enabled *bool `json:"enabled"` AllApplications *bool `json:"allApplications"` @@ -274,6 +278,44 @@ func UpdateNotificationHandler(c *gin.Context) { sse.PublishUpdate(sse.TopicNotifications) } +// SetNotificationEnabledHandler only toggles the enabled flag, so it cannot overwrite a concurrent +// edit of the subscription. +func SetNotificationEnabledHandler(c *gin.Context) { + id := c.Param("id") + + var req setNotificationEnabledRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: enabled is required"}) + return + } + + ctx := c.Request.Context() + rowsAffected, err := gorm.G[models.Notification](db.DB). + Where("id = ?", id). + Update(ctx, "enabled", *req.Enabled) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + + notification, err := getNotificationById(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + c.JSON(http.StatusOK, toNotificationResponse(¬ification)) + sse.PublishUpdate(sse.TopicNotifications) +} + func DeleteNotificationHandler(c *gin.Context) { id := c.Param("id") diff --git a/backend/internal/hub/routes/notifications_test.go b/backend/internal/hub/routes/notifications_test.go index ab33a959..844f1943 100644 --- a/backend/internal/hub/routes/notifications_test.go +++ b/backend/internal/hub/routes/notifications_test.go @@ -949,6 +949,82 @@ func TestUpdateNotificationHandler_RejectsInvalidEvents(t *testing.T) { } } +func setNotificationEnabled(t *testing.T, id string, body any) *httptest.ResponseRecorder { + t.Helper() + + reqBody, _ := json.Marshal(body) + router := gin.New() + router.PUT("/api/v1/notifications/:id/enabled", SetNotificationEnabledHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/"+id+"/enabled", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + return w +} + +func TestSetNotificationEnabledHandler_TogglesOnlyEnabled(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + app := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + notification := createNotificationRecord(t, []string{app.Id}) + + for _, enabled := range []bool{false, true} { + w := setNotificationEnabled(t, notification.Id, map[string]any{"enabled": enabled}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if body.Enabled != enabled { + t.Fatalf("expected enabled=%v, got %v", enabled, body.Enabled) + } + if !slices.Equal(body.ApplicationIds, []string{app.Id}) { + t.Fatalf("expected applicationIds [%s] to be kept, got %v", app.Id, body.ApplicationIds) + } + if !slices.Equal(body.Events, models.NotificationEvents) { + t.Fatalf("expected events to be kept, got %v", body.Events) + } + } + + stored, err := gorm.G[models.Notification](db.DB).Where("id = ?", notification.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load stored notification: %v", err) + } + if !stored.Enabled { + t.Fatal("expected stored notification to be enabled") + } + if stored.Config.String() != validDiscordConfig { + t.Fatalf("expected stored config to remain unchanged, got %q", stored.Config.String()) + } +} + +func TestSetNotificationEnabledHandler_NotFound(t *testing.T) { + setupTestDBWithNotifications(t) + + w := setNotificationEnabled(t, "missing", map[string]any{"enabled": true}) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSetNotificationEnabledHandler_InvalidRequest(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + for _, body := range []any{map[string]any{}, map[string]any{"enabled": "yes"}} { + w := setNotificationEnabled(t, notification.Id, body) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for %v, got %d: %s", body, w.Code, w.Body.String()) + } + } +} + func TestDeleteNotificationHandler_Success(t *testing.T) { setupTestDBWithNotifications(t) diff --git a/frontend/src/components/cards/agent-card.tsx b/frontend/src/components/cards/agent-card.tsx index 4acf4b57..bcd07d6c 100644 --- a/frontend/src/components/cards/agent-card.tsx +++ b/frontend/src/components/cards/agent-card.tsx @@ -6,7 +6,7 @@ import { StaticLucideIcon } from "@/components/lucide-icon-picker"; import { Skeleton } from "@/components/ui/skeleton"; import { AgentStatus, type Agent } from "@/lib/agents"; import type { ApplicationListItem } from "@/lib/applications"; -import { fleetCategoryConfig, getFleetCategory } from "@/lib/fleet"; +import { fleetCategories, fleetCategoryConfig, getFleetCategory } from "@/lib/fleet"; import { m } from "@/lib/paraglide/messages"; import { cn, formatRelativeTime } from "@/lib/utils"; import { ResourceCard } from "./resource-card"; @@ -81,10 +81,20 @@ function ApplicationList({ ); } + // Apps that need attention first, as only the first few are listed. + const listed = apps + .map((app) => ({ app, category: getFleetCategory(app) })) + .sort( + (a, b) => + fleetCategories.indexOf(a.category) - fleetCategories.indexOf(b.category) || + a.app.name.localeCompare(b.app.name), + ) + .slice(0, maxListedApplications); + return (
      - {apps.slice(0, maxListedApplications).map((app) => { - const category = fleetCategoryConfig[getFleetCategory(app)]; + {listed.map(({ app, category: fleetCategory }) => { + const category = fleetCategoryConfig[fleetCategory]; return (
    • } title={app.name} - subtitle={`${app.repositoryName} / ${app.branch}`} + subtitle={`${app.repositoryName} @ ${app.branch}`} monoSubtitle action={ diff --git a/frontend/src/components/cards/notification-card.tsx b/frontend/src/components/cards/notification-card.tsx index 26fb63a3..8c0e6828 100644 --- a/frontend/src/components/cards/notification-card.tsx +++ b/frontend/src/components/cards/notification-card.tsx @@ -13,9 +13,8 @@ import { API_BASE } from "@/lib/api"; import { getNotificationResourceEvents, getNotificationTypeIconPath, - normalizeNotificationSubscription, notificationResources, - updateNotification, + setNotificationEnabled, type Notification, type NotificationResource, type NotificationStatus, @@ -129,18 +128,7 @@ function EnabledSwitch({ notification }: { notification: Notification }) { const handleChange = async (checked: boolean) => { setPending(checked); try { - await updateNotification(notification.id, { - enabled: checked, - ...normalizeNotificationSubscription({ - events: notification.events, - allApplications: notification.allApplications, - applicationIds: notification.applicationIds, - allAgents: notification.allAgents, - agentIds: notification.agentIds, - allRepositories: notification.allRepositories, - repositoryIds: notification.repositoryIds, - }), - }); + await setNotificationEnabled(notification.id, checked); await mutate(`${API_BASE}/notifications`); toast.add({ type: "success", title: m.notificationUpdated() }); } catch (err) { diff --git a/frontend/src/components/cards/repository-card.tsx b/frontend/src/components/cards/repository-card.tsx index 51af382d..d007a08d 100644 --- a/frontend/src/components/cards/repository-card.tsx +++ b/frontend/src/components/cards/repository-card.tsx @@ -13,7 +13,7 @@ import { type Repository, type RepositorySyncStatus, } from "@/lib/repositories"; -import { cn } from "@/lib/utils"; +import { cn, formatInterval } from "@/lib/utils"; import { RelativeTime, ResourceCard } from "./resource-card"; const maxListedApplications = 2; @@ -27,16 +27,6 @@ const syncStatusIcon: Record = { unknown: , }; -function formatInterval(seconds: number): string { - if (seconds % 3600 === 0) { - return `${seconds / 3600}h`; - } - if (seconds % 60 === 0) { - return `${seconds / 60}m`; - } - return `${seconds}s`; -} - function getTriggerLabel(repository: Repository): string { if (repository.syncType === "polling" && repository.pollingIntervalSeconds) { return m.cardPollingEvery({ interval: formatInterval(repository.pollingIntervalSeconds) }); @@ -142,9 +132,9 @@ export function RepositoryCard({ {getSyncTypeIcon(repository.syncType, "size-3.5 shrink-0")} {getTriggerLabel(repository)}