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/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/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/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); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2febd83a..f779f2a9 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", + "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", "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..6615c70f 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", + "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", "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..3bdad9ae --- /dev/null +++ b/frontend/src/components/cards/agent-card.tsx @@ -0,0 +1,172 @@ +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 { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip"; +import { StaticLucideIcon } from "@/components/lucide-icon-picker"; +import { RelativeTime } from "@/components/relative-time"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AgentStatus, type Agent } from "@/lib/agents"; +import type { ApplicationListItem } from "@/lib/applications"; +import { fleetCategories, fleetCategoryConfig, getFleetCategory } from "@/lib/fleet"; +import { m } from "@/lib/paraglide/messages"; +import { cn } from "@/lib/utils"; +import { ResourceCard } from "./resource-card"; + +const maxListedApplications = 3; + +function ConnectionStatus({ agent }: { agent: Agent }) { + if (agent.status === AgentStatus.Offline) { + return ( + + + {m.statusOffline()} + + ·{" "} + m.applicationsAgentsLastSeen({ time })} + fallback={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 ? ( + + } + className="cursor-help truncate text-amber-600 dark:text-amber-400" + > + · {m.applicationsAgentsDockerUnreachable()} + + {m.agentStatusErrorDescription()} + + ) : null} + + ); +} + +function ApplicationList({ + agentId, + apps, +}: { + agentId: string; + apps: ApplicationListItem[] | undefined; +}) { + if (!apps) { + return ( +
+ + +
+ ); + } + + if (apps.length === 0) { + return ( +

+ {m.cardAgentNoApplications()} +

+ ); + } + + // 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 ( +
    + {listed.map(({ app, category: fleetCategory }) => { + const category = fleetCategoryConfig[fleetCategory]; + 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} + 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 new file mode 100644 index 00000000..7773c79a --- /dev/null +++ b/frontend/src/components/cards/application-card.tsx @@ -0,0 +1,83 @@ +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 { RelativeTime } from "@/components/relative-time"; +import { HealthStatus, SyncStatus, type ApplicationListItem } from "@/lib/applications"; +import { m } from "@/lib/paraglide/messages"; +import { CardError, ResourceCard, ResourceFlow, 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 ( + // The link overlays the card instead of wrapping it, as interactive content like the timestamp + // tooltip must not be nested inside a link. +
+ + } + title={app.name} + subtitle={`${app.repositoryName} @ ${app.branch}`} + monoSubtitle + action={ + + } + footer={ + <> + + + {app.agentName} + + + + } + > + + {app.lastSyncError ? : 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..aedddc1c --- /dev/null +++ b/frontend/src/components/cards/notification-card.tsx @@ -0,0 +1,198 @@ +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 { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip"; +import { + notificationEventLabels, + resourceConfigs, +} from "@/components/dialogs/notification-subscription-fields"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/components/ui/toast"; +import { API_BASE } from "@/lib/api"; +import { + getNotificationResourceEvents, + getNotificationTypeIconPath, + notificationResources, + setNotificationEnabled, + type Notification, + type NotificationResource, + type NotificationStatus, +} from "@/lib/notifications"; +import { m } from "@/lib/paraglide/messages"; +import { cn } from "@/lib/utils"; +import { ResourceCard } from "./resource-card"; + +const resourceIcons: Record = { + applications: AppWindow, + agents: Server, + repositories: GitBranch, +}; + +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} + + + } + className="flex shrink-0 cursor-help items-center gap-1 rounded-sm p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring" + aria-label={m.cardSubscribedEvents({ + count: subscribed.length, + total: events.length, + })} + > + + {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 setNotificationEnabled(notification.id, checked); + 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, +}: { + notification: Notification; + action?: ReactNode; +}) { + return ( + + ) : ( + {m.notificationProviderAlt()} + ) + } + title={notification.name} + subtitle={notification.type.charAt(0).toUpperCase() + notification.type.slice(1)} + action={action} + footer={ + <> + + + {m.cardLastDelivery()} + + {getNotificationStatusLabel(notification.status)} + + + + + } + > +
      + {notificationResources.map((resource) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/src/components/cards/repository-card.tsx b/frontend/src/components/cards/repository-card.tsx new file mode 100644 index 00000000..78681ed4 --- /dev/null +++ b/frontend/src/components/cards/repository-card.tsx @@ -0,0 +1,149 @@ +import { useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { AppWindow, CircleCheck, CircleDashed, CircleX, RefreshCw } from "lucide-react"; +import { RelativeTime } from "@/components/relative-time"; +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, + getGitProviderIconPath, + type Repository, + type RepositorySyncStatus, +} from "@/lib/repositories"; +import { cn, formatInterval } from "@/lib/utils"; +import { ResourceCard } from "./resource-card"; + +const maxListedApplications = 2; + +const syncStatusIcon: Record = { + success: , + failed: , + syncing: ( + + ), + unknown: , +}; + +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} + footer={} + > +
    +
    +
    + {syncStatusIcon[repository.syncStatus]} + + {getRepositorySyncStatusLabel(repository.syncStatus)} + + +
    + {failed && repository.lastSyncError ? ( +

    + {repository.lastSyncError} +

    + ) : null} +
    +
    + {getSyncTypeIcon(repository.syncType, "size-3.5 shrink-0")} + {getTriggerLabel(repository)} + +
    +
    +
    + ); +} diff --git a/frontend/src/components/cards/resource-card.tsx b/frontend/src/components/cards/resource-card.tsx new file mode 100644 index 00000000..dcdd8b03 --- /dev/null +++ b/frontend/src/components/cards/resource-card.tsx @@ -0,0 +1,175 @@ +import type { ReactNode } from "react"; +import { CircleAlert } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { cn } 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. + */ +export 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} +
    +
    + ))} +
    +
    + ); +} + +export function CardError({ message, className }: { message: string; className?: string }) { + return ( +

    + + {message} +

    + ); +} + +interface ResourceCardProps { + icon: ReactNode; + title: string; + subtitle?: ReactNode; + monoSubtitle?: boolean; + action?: ReactNode; + 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, + children, + footer, + className, +}: ResourceCardProps) { + return ( + +
    +
    + {icon} +
    +
    +

    + {title} +

    + {subtitle ? ( +
    + {subtitle} +
    + ) : null} +
    + {action ?
    {action}
    : null} +
    + +
    {children}
    + +
    + {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/notifications.ts b/frontend/src/lib/notifications.ts index c54b4afe..6fed5e49 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -106,6 +106,10 @@ export function updateNotification( return fetcher(`/notifications/${id}`, "PUT", data); } +export function setNotificationEnabled(id: string, enabled: boolean): Promise { + return fetcher(`/notifications/${id}/enabled`, "PUT", { enabled }); +} + export function deleteNotification(id: string): Promise { return fetcher(`/notifications/${id}`, "DELETE"); } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 298394c3..d046bcd5 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -73,6 +73,17 @@ export function formatRelativeTime(value: string | Date, now = Date.now()): stri return formatter.format(seconds, "second"); } +/** Formats an interval in its largest whole unit, e.g. 300 → "5m", 7200 → "2h", 90 → "90s". */ +export function formatInterval(seconds: number): string { + if (seconds > 0 && seconds % 3600 === 0) { + return `${seconds / 3600}h`; + } + if (seconds > 0 && seconds % 60 === 0) { + return `${seconds / 60}m`; + } + return `${seconds}s`; +} + export function formatDuration(ms: number): string { const seconds = Math.max(0, Math.round(ms / 1000)); if (seconds < 60) { @@ -86,3 +97,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 334a8c5d..6b21574f 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, @@ -16,17 +16,15 @@ 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"; -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, @@ -41,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,91 +121,42 @@ 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 f93a2261..da2bda64 100644 --- a/frontend/src/routes/_authenticated/applications/index.tsx +++ b/frontend/src/routes/_authenticated/applications/index.tsx @@ -1,21 +1,17 @@ -import { RelativeTime } from "@/components/relative-time"; 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, @@ -189,63 +185,7 @@ function ApplicationsPage() { ) : null}
    {filteredApps.map((app) => ( -
    - - - - -
    -
    - -
    -
    -

    - {app.name} -

    -
    -
    -
    - - - -
    - -
    - - -
    - - - -
    -
    - - {app.agentName} -
    -
    - - - {app.repositoryName} @ {app.branch} - -
    -
    -
    - - {app.commit.slice(0, 7)} -
    - - - -
    -
    -
    -
    -
    + ))}
    diff --git a/frontend/src/routes/_authenticated/notifications/index.tsx b/frontend/src/routes/_authenticated/notifications/index.tsx index 6ddbdba3..a466ef10 100644 --- a/frontend/src/routes/_authenticated/notifications/index.tsx +++ b/frontend/src/routes/_authenticated/notifications/index.tsx @@ -1,10 +1,8 @@ -import { RelativeTime } from "@/components/relative-time"; 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"; @@ -12,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, @@ -25,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"; @@ -126,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()} - - - - -
    -
    -
    + + + + } + /> ))}
    diff --git a/frontend/src/routes/_authenticated/repositories/index.tsx b/frontend/src/routes/_authenticated/repositories/index.tsx index b2365d09..cd949055 100644 --- a/frontend/src/routes/_authenticated/repositories/index.tsx +++ b/frontend/src/routes/_authenticated/repositories/index.tsx @@ -1,23 +1,17 @@ -import { RelativeTime } from "@/components/relative-time"; 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 type { ApplicationListItem } from "@/lib/applications"; 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,9 +23,7 @@ import { } from "@/components/ui/dropdown-menu"; 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 { groupBy, toSearchableText } from "@/lib/utils"; import { usePreferredLayout } from "@/lib/layout-preference"; import { LayoutToggleGroup } from "@/components/layout-toggle-group"; @@ -48,6 +40,11 @@ export const Route = createFileRoute("/_authenticated/repositories/")({ function RepositoriesPage() { const { data, isLoading } = useFetch("/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(); @@ -132,100 +129,60 @@ 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={repository} + apps={ + applicationsByRepository && (applicationsByRepository.get(repository.id) ?? []) + } + onSync={() => handleSyncRepo(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