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
- {getStatusLabel(status)}
+ {getNotificationStatusLabel(status)}
);
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 (
+
+ ) : (
+
+ )
+ }
+ 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) => (
+
0 && "flex-1")}>
+ {index > 0 ? (
+ firstBroken}
+ />
+ ) : null}
+
+
+ ))}
+
+
+ {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.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
- />
-
-
-
-
-
-
-
-
})
-
-
-
- {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