diff --git a/frontend/src/app/dashboard/admin/apps/page.tsx b/frontend/src/app/dashboard/admin/apps/page.tsx index d017aa6..b48b435 100644 --- a/frontend/src/app/dashboard/admin/apps/page.tsx +++ b/frontend/src/app/dashboard/admin/apps/page.tsx @@ -8,6 +8,7 @@ import { toast } from 'react-toastify'; import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types'; import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { TruncatedText } from '@/components/truncated-text'; import { useDebounce } from '@/hooks/useDebounce'; const statusColors: Record = { @@ -60,6 +61,25 @@ function formatDeletionDate(date?: string): string { return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } +function migrationStatusBadgeClass(status: ApplicationMigrationJob['status']): string { + switch (status) { + case 'completed': + return 'badge-green'; + case 'failed': + case 'rolled_back': + return 'badge-red'; + case 'running': + case 'rolling_back': + return 'badge-blue'; + default: + return 'badge-gray'; + } +} + +function formatMigrationStatus(status: ApplicationMigrationJob['status']): string { + return status.replace(/_/g, ' '); +} + export default function AdminAppsPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); @@ -267,17 +287,18 @@ export default function AdminAppsPage() { ) : ( <> {/* Desktop Table */} -
- +
+
- + + - + @@ -289,106 +310,121 @@ export default function AdminAppsPage() { const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined; return ( - - - - - - +
ApplicationApplication Owner Status Service ClusterMigration Plan / ExpiryActionsActions
- + +
-
- +
+ {app.name} - - {app.runtime} + + {app.runtime}
+ {app.user ? ( -
-

- {app.user.firstName} {app.user.lastName} -

-

{app.user.email}

-

{app.userId.slice(0, 8)}

+
+ + {`${app.user.firstName} ${app.user.lastName}`} + + {app.user.email} + {app.userId}
) : ( - {app.userId.slice(0, 8)} + {app.userId} )}
- {latestStatus} + + + {latestStatus} + - - {lifecycle === 'suspended' && } - {lifecycleLabels[lifecycle] || lifecycle} + + + {lifecycle === 'suspended' && } + {lifecycleLabels[lifecycle] || lifecycle} {lifecycle === 'pending_deletion' && app.scheduledDeletionAt && ( -

Delete: {formatDeletionDate(app.scheduledDeletionAt)}

+ + {`Delete: ${formatDeletionDate(app.scheduledDeletionAt)}`} + )}
+ {assignedCluster ? ( -
-

{assignedCluster.name}

-

- {assignedCluster.region || 'N/A'} · {assignedCluster.status}/{assignedCluster.healthStatus || 'unknown'} -

+
+ {assignedCluster.name} + + {`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}/${assignedCluster.healthStatus || 'unknown'}`} +
) : app.clusterId ? ( -
-

Unknown cluster

-

{app.clusterId.slice(0, 8)}

+
+ Unknown cluster + {app.clusterId}
) : ( - Not assigned + Not assigned )}
- {app.billingCycle && ( - {cycleLabels[app.billingCycle] || app.billingCycle} - )} - {app.planExpiresAt ? ( - - {expiry.text} + + {latestMigration ? ( + + {formatMigrationStatus(latestMigration.status)} ) : ( - No plan + + )} + + {app.billingCycle && ( + + {cycleLabels[app.billingCycle] || app.billingCycle} + + )} + {app.planExpiresAt ? ( + + {expiry.text} + + ) : ( + No plan )} -
+
View - {latestMigration && ( - - {latestMigration.status} - - )} @@ -401,48 +437,122 @@ export default function AdminAppsPage() {
- {/* Mobile Cards */} -
+ {/* Mobile / tablet cards */} +
{apps.map((app) => { const latestStatus = app.deployments?.[0]?.status || 'pending'; + const lifecycle = app.lifecycleStatus || 'active'; + const expiry = formatExpiry(app.planExpiresAt); + const latestMigration = migrations.find((migration) => migration.applicationId === app.id); + const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined; return ( - -
-
-
+
+ +
-
-

{app.name}

+
+ {app.name}

{app.runtime}

-
- + + {latestStatus}
+ {app.user && ( -
- {app.user.firstName} {app.user.lastName} - - {app.user.email} +
+

+ + {app.user.firstName} {app.user.lastName} +

+ {app.user.email}
)} -
- {app.databaseType} - {app.replicas} replica{app.replicas > 1 ? 's' : ''} -
-
- Cluster:{' '} - - {app.clusterId ? (clusterById.get(app.clusterId)?.name || app.clusterId.slice(0, 8)) : 'Not assigned'} + +
+ + {lifecycle === 'suspended' && } + {lifecycleLabels[lifecycle] || lifecycle} + {app.billingCycle && ( + {cycleLabels[app.billingCycle] || app.billingCycle} + )} + {latestMigration && ( + + {formatMigrationStatus(latestMigration.status)} + + )}
- + + {lifecycle === 'pending_deletion' && app.scheduledDeletionAt && ( +

Delete: {formatDeletionDate(app.scheduledDeletionAt)}

+ )} + +
+
+ Cluster + + {assignedCluster?.name || (app.clusterId ? 'Unknown' : 'Not assigned')} + + {assignedCluster && ( + + {`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}`} + + )} +
+
+ Plan + {app.planExpiresAt ? ( + {expiry.text} + ) : ( + No plan + )} +
+
+ {app.databaseType} + {app.replicas} replica{app.replicas > 1 ? 's' : ''} +
+
+ +
+ + View + + + +
+
); })}
diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 30ac003..f58f8fb 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -9,7 +9,6 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import NextLink from 'next/link'; import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; -import { BuildProgressModal } from '@/components/build-progress-modal'; import { useAuthStore } from '@/lib/store'; /** Matches backend multipart limit for POST /applications/:id/upload */ @@ -547,6 +546,7 @@ export default function AppDetailPage() { const invalidateAll = () => { queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); + queryClient.invalidateQueries({ queryKey: ['applications'] }); }; const deployMutation = useMutation({ @@ -2951,7 +2951,6 @@ export default function AppDetailPage() {
)}
-
); } diff --git a/frontend/src/app/dashboard/apps/page.tsx b/frontend/src/app/dashboard/apps/page.tsx index 7cbc54b..61630ab 100644 --- a/frontend/src/app/dashboard/apps/page.tsx +++ b/frontend/src/app/dashboard/apps/page.tsx @@ -120,7 +120,7 @@ export default function AppsPage() { - + @@ -138,8 +138,8 @@ export default function AppsPage() { key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`} > - - + @@ -343,7 +343,7 @@ function LogsPageContent() { {entry.level?.toUpperCase()} - +
ApplicationApplication Runtime Status Service Status - + +
diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index e8c6ae1..9f64ad9 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -6,6 +6,8 @@ import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '@/lib/store'; import api from '@/lib/api'; +import { DeploymentProgressManager } from '@/components/deployment-progress-manager'; +import { useDeployProgressStore } from '@/lib/deploy-progress-store'; import { LayoutDashboard, Package, @@ -66,6 +68,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const router = useRouter(); const pathname = usePathname(); const [sidebarOpen, setSidebarOpen] = useState(false); + const deployBarMinimized = useDeployProgressStore((s) => s.minimized); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -205,6 +208,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return (
+ {/* Mobile overlay */} {sidebarOpen && (
{/* Header */} -
+
diff --git a/frontend/src/app/dashboard/logs/page.tsx b/frontend/src/app/dashboard/logs/page.tsx index 4d58cd3..f47ae30 100644 --- a/frontend/src/app/dashboard/logs/page.tsx +++ b/frontend/src/app/dashboard/logs/page.tsx @@ -327,7 +327,7 @@ function LogsPageContent() {
Time LevelAppApp Source Message
{entry.applicationName || '—'}{entry.applicationName || '—'} {entry.workload || 'app'} {entry.message} diff --git a/frontend/src/components/build-progress-modal.tsx b/frontend/src/components/build-progress-modal.tsx index 6572efd..51d5a56 100644 --- a/frontend/src/components/build-progress-modal.tsx +++ b/frontend/src/components/build-progress-modal.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; -import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X } from 'lucide-react'; +import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X, Minimize2 } from 'lucide-react'; import { toast } from 'react-toastify'; export interface BuildProgress { @@ -20,7 +19,7 @@ function formatBytes(bytes?: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } -const phaseConfig = { +export const phaseConfig = { uploading: { label: 'Uploading source to cluster', icon: Upload, bg: 'bg-blue-500' }, building: { label: 'Building Docker image', icon: Hammer, bg: 'bg-amber-500' }, deploying: { label: 'Deploying to Kubernetes', icon: Rocket, bg: 'bg-purple-500' }, @@ -29,36 +28,31 @@ const phaseConfig = { cancelled: { label: 'Deployment cancelled', icon: XCircle, bg: 'bg-gray-500' }, }; -export function BuildProgressModal({ appId, enabled }: { appId: string; enabled: boolean }) { +export function BuildProgressModal({ + appId, + appName, + progress, + onMinimize, +}: { + appId: string; + appName?: string; + progress: BuildProgress; + onMinimize: () => void; +}) { const queryClient = useQueryClient(); - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - if (enabled) setDismissed(false); - }, [enabled]); - - const { data } = useQuery<{ progress: BuildProgress | null }>({ - queryKey: ['build-progress', appId], - queryFn: () => api.get(`/deployments/applications/${appId}/build-progress`).then((r) => r.data), - enabled: enabled && !dismissed, - refetchInterval: enabled && !dismissed ? 1500 : false, - }); const cancelMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`), onSuccess: () => { toast.success('Deployment cancelled'); - setDismissed(true); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); queryClient.invalidateQueries({ queryKey: ['build-progress', appId] }); + queryClient.invalidateQueries({ queryKey: ['applications'] }); }, onError: () => toast.error('Failed to cancel deployment'), }); - const progress = data?.progress; - if (!enabled || dismissed || !progress || progress.phase === 'done') return null; - const cfg = phaseConfig[progress.phase]; const PhaseIcon = cfg.icon; const isActive = progress.phase !== 'failed' && progress.phase !== 'cancelled'; @@ -67,64 +61,78 @@ export function BuildProgressModal({ appId, enabled }: { appId: string; enabled: return (
-
+
+
+ {isActive && ( + + )} +
-
- {isActive ? ( - - ) : ( - - )} -

{cfg.label}

- {progress.message && ( -

{progress.message}

- )} -
- - {isActive && ( -
-
- - - {progress.percent}% - - {showBytes && ( - {formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)} - )} -
-
-
-
-
+
+ {isActive ? ( + + ) : ( + )} - - {progress.phase === 'failed' && progress.message && ( -

{progress.message}

- )} - - {progress.phase === 'cancelled' && progress.message && ( -

{progress.message}

- )} - - {isActive && ( -

- Click the close button to cancel and remove cluster build resources. -

+

{cfg.label}

+ {appName &&

{appName}

} + {progress.message && ( +

{progress.message}

)}
+ + {isActive && ( +
+
+ + + {progress.percent}% + + {showBytes && ( + {formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)} + )} +
+
+
+
+
+ )} + + {progress.phase === 'failed' && progress.message && ( +

{progress.message}

+ )} + + {progress.phase === 'cancelled' && progress.message && ( +

{progress.message}

+ )} + + {isActive && ( +

+ Use minimize to keep working while deployment continues. Close (×) cancels the deployment. +

+ )} +
); } diff --git a/frontend/src/components/deployment-progress-bar.tsx b/frontend/src/components/deployment-progress-bar.tsx new file mode 100644 index 0000000..e74189c --- /dev/null +++ b/frontend/src/components/deployment-progress-bar.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; +import type { Application } from '@/types'; +import type { BuildProgress } from '@/components/build-progress-modal'; +import { phaseConfig } from '@/components/build-progress-modal'; + +type DeployingApp = { + app: Application; + progress: BuildProgress | null; +}; + +export function DeploymentProgressBar({ + items, + onExpand, +}: { + items: DeployingApp[]; + onExpand: (appId: string) => void; +}) { + const [index, setIndex] = useState(0); + + useEffect(() => { + setIndex((i) => (items.length ? Math.min(i, items.length - 1) : 0)); + }, [items.length]); + + if (items.length === 0) return null; + + const current = items[index]; + const progress = current.progress; + const phase = progress?.phase ?? 'building'; + const cfg = phaseConfig[phase] ?? phaseConfig.building; + const percent = progress?.percent ?? 0; + const multiple = items.length > 1; + + const goPrev = () => setIndex((i) => (i - 1 + items.length) % items.length); + const goNext = () => setIndex((i) => (i + 1) % items.length); + + return ( +
onExpand(current.app.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onExpand(current.app.id); + } + }} + className="w-full bg-primary-600 text-white text-left hover:bg-primary-700 transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300 focus-visible:ring-offset-2" + aria-label={`View deployment progress for ${current.app.name}`} + > +
+ {multiple && ( + + )} + + + + + + {current.app.name} + {multiple && ( + + {index + 1} / {items.length} + + )} + + + {cfg.label} + {progress?.message ? ` · ${progress.message}` : ''} + + + + {percent}% + +
+
+
+ + {multiple && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/deployment-progress-manager.tsx b/frontend/src/components/deployment-progress-manager.tsx new file mode 100644 index 0000000..11ca898 --- /dev/null +++ b/frontend/src/components/deployment-progress-manager.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { usePathname } from 'next/navigation'; +import { useQuery, useQueries } from '@tanstack/react-query'; +import api from '@/lib/api'; +import type { Application } from '@/types'; +import { useAuthStore } from '@/lib/store'; +import { useDeployProgressStore } from '@/lib/deploy-progress-store'; +import { getAppsInProgress, isActiveBuildProgress } from '@/lib/deployment-progress'; +import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal'; +import { DeploymentProgressBar } from '@/components/deployment-progress-bar'; + +export function DeploymentProgressManager() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const pathname = usePathname(); + const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore(); + + const { data: apps = [] } = useQuery({ + queryKey: ['applications'], + queryFn: () => api.get('/applications').then((r) => r.data), + enabled: isAuthenticated, + refetchInterval: (query) => { + const list = query.state.data ?? []; + return getAppsInProgress(list).length > 0 ? 3000 : false; + }, + }); + + const deployingApps = useMemo(() => getAppsInProgress(apps), [apps]); + + const progressQueries = useQueries({ + queries: deployingApps.map((app) => ({ + queryKey: ['build-progress', app.id], + queryFn: () => + api + .get<{ progress: BuildProgress | null }>(`/deployments/applications/${app.id}/build-progress`) + .then((r) => r.data.progress), + refetchInterval: 1500, + })), + }); + + const activeItems = useMemo(() => { + return deployingApps + .map((app, i) => ({ + app, + progress: progressQueries[i]?.data ?? null, + })) + .filter(({ progress }) => isActiveBuildProgress(progress)); + }, [deployingApps, progressQueries]); + + const routeAppId = pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1]; + + useEffect(() => { + if (activeItems.length === 0) { + useDeployProgressStore.setState({ minimized: false, focusedAppId: null }); + } + }, [activeItems.length]); + + useEffect(() => { + if (activeItems.length === 0) return; + const { focusedAppId: current } = useDeployProgressStore.getState(); + const ids = new Set(activeItems.map((item) => item.app.id)); + if (current && ids.has(current)) return; + const preferred = + (routeAppId && ids.has(routeAppId) ? routeAppId : null) ?? activeItems[0].app.id; + useDeployProgressStore.setState({ focusedAppId: preferred }); + }, [activeItems, routeAppId]); + + const focusedItem = + activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0]; + + const focusedProgress = focusedItem?.progress; + const showModal = + !minimized && + !!focusedItem && + !!focusedProgress && + isActiveBuildProgress(focusedProgress); + + const handleMinimize = () => { + if (focusedItem) minimize(focusedItem.app.id); + }; + + const handleExpand = (appId: string) => { + expand(appId); + }; + + if (activeItems.length === 0) return null; + + return ( + <> + {minimized && ( +
+ +
+ )} + + {showModal && focusedProgress && ( + + )} + + ); +} diff --git a/frontend/src/components/truncated-text.tsx b/frontend/src/components/truncated-text.tsx new file mode 100644 index 0000000..1a6130e --- /dev/null +++ b/frontend/src/components/truncated-text.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; + +type TruncatedTextProps = { + children: string; + className?: string; + /** Native tooltip; defaults to children */ + title?: string; +}; + +/** Single-line ellipsis with native tooltip on hover */ +export function TruncatedText({ children, className, title }: TruncatedTextProps) { + const tooltip = title ?? children; + return ( + + {children} + + ); +} diff --git a/frontend/src/lib/deploy-progress-store.ts b/frontend/src/lib/deploy-progress-store.ts new file mode 100644 index 0000000..d2117ed --- /dev/null +++ b/frontend/src/lib/deploy-progress-store.ts @@ -0,0 +1,21 @@ +'use client'; + +import { create } from 'zustand'; + +interface DeployProgressState { + minimized: boolean; + focusedAppId: string | null; + minimize: (appId: string) => void; + expand: (appId?: string) => void; +} + +export const useDeployProgressStore = create((set) => ({ + minimized: false, + focusedAppId: null, + minimize: (appId) => set({ minimized: true, focusedAppId: appId }), + expand: (appId) => + set((state) => ({ + minimized: false, + focusedAppId: appId ?? state.focusedAppId, + })), +})); diff --git a/frontend/src/lib/deployment-progress.ts b/frontend/src/lib/deployment-progress.ts new file mode 100644 index 0000000..934693f --- /dev/null +++ b/frontend/src/lib/deployment-progress.ts @@ -0,0 +1,25 @@ +import type { Application, Deployment, DeploymentStatus } from '@/types'; +import type { BuildProgress } from '@/components/build-progress-modal'; + +export function isDeploymentInProgress(status?: DeploymentStatus | string): boolean { + return status === 'building' || status === 'deploying' || status === 'pending'; +} + +export function getLatestDeployment(deployments?: Deployment[]): Deployment | undefined { + if (!deployments?.length) return undefined; + return [...deployments].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; +} + +export function getAppsInProgress(apps: Application[]): Application[] { + return apps.filter((app) => { + const latest = getLatestDeployment(app.deployments); + return isDeploymentInProgress(latest?.status); + }); +} + +export function isActiveBuildProgress(progress: BuildProgress | null | undefined): boolean { + if (!progress) return true; + return progress.phase !== 'done' && progress.phase !== 'failed' && progress.phase !== 'cancelled'; +}