diff --git a/backend/src/__tests__/cache-endpoints.test.ts b/backend/src/__tests__/cache-endpoints.test.ts index 2021f7ad..93c1f236 100644 --- a/backend/src/__tests__/cache-endpoints.test.ts +++ b/backend/src/__tests__/cache-endpoints.test.ts @@ -17,6 +17,8 @@ import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb'; +import { GitSourceService } from '../services/GitSourceService'; +import type { PublicGitSource } from '../services/GitSourceService'; // ── Hoisted mocks (must come before importing the app) ───────────────── @@ -219,6 +221,45 @@ describe('GET /api/stacks/statuses caching', () => { await request(app).get('/api/stacks/statuses').set('Cookie', authCookie); expect(mockGetStacks).toHaveBeenCalledTimes(2); }); + + it('labels each stack with its git/local source, computed outside the cache', async () => { + mockGetStacks.mockResolvedValue(['web.yml', 'db.yml']); + mockGetBulkStackStatuses.mockResolvedValue({ + web: { status: 'running' }, + db: { status: 'running' }, + }); + // Only `web` is linked to a Git source. + const listSpy = vi + .spyOn(GitSourceService.getInstance(), 'list') + .mockReturnValue([{ stack_name: 'web' } as PublicGitSource]); + + const first = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie); + expect(first.body['web.yml'].source).toBe('git'); + expect(first.body['db.yml'].source).toBe('local'); + + // Source is recomputed live even when the Docker-status payload is cached: + // unlinking `web` flips it to local on the next request without a cache flush. + listSpy.mockReturnValue([]); + const second = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie); + expect(second.body['web.yml'].source).toBe('local'); + expect(mockGetBulkStackStatuses).toHaveBeenCalledTimes(1); // status portion served from cache + + listSpy.mockRestore(); + }); + + it('falls back to local labels (200, not 500) when the git-source lookup throws', async () => { + mockGetStacks.mockResolvedValue(['web.yml']); + mockGetBulkStackStatuses.mockResolvedValue({ web: { status: 'running' } }); + const listSpy = vi + .spyOn(GitSourceService.getInstance(), 'list') + .mockImplementation(() => { throw new Error('db locked'); }); + + const res = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body['web.yml'].source).toBe('local'); + + listSpy.mockRestore(); + }); }); // ── /api/system/cache-stats ──────────────────────────────────────────── diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 8a47dfe4..c6455e35 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -252,7 +252,23 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => { return data; }, ); - res.json(result); + // Git-source labels are computed live, outside the cache, so linking or + // unlinking a stack's Git source is reflected immediately. The Docker + // status portion keeps its short TTL; only the cheap source label is fresh. + // The label is cosmetic, so a lookup failure must not take down the primary + // status payload: fall back to labeling everything 'local'. + let gitStackNames = new Set(); + try { + gitStackNames = new Set(GitSourceService.getInstance().list().map((s) => s.stack_name)); + } catch (sourceError) { + console.error('Failed to load git sources for status labels; defaulting to local:', sourceError); + } + const withSource: Record = {}; + for (const [stack, info] of Object.entries(result)) { + const name = stack.replace(/\.(yml|yaml)$/, ''); + withSource[stack] = { ...info, source: gitStackNames.has(name) ? 'git' : 'local' }; + } + res.json(withSource); } catch (error) { console.error('Failed to fetch stack statuses:', error); res.status(500).json({ error: 'Failed to fetch stack statuses' }); diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 0906e085..06ef0446 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -48,7 +48,6 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection stackStatuses={data.stackStatuses} metrics={data.metrics} stackCpuSeries={data.stackCpuSeries} - activeNodeName={activeNodeName} onNavigateToStack={onNavigateToStack ?? NOOP} /> diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index a1832585..2597ebf9 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -8,12 +8,8 @@ import { springs } from '@/lib/motion'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from "@/components/ui/modal"; +import { ConfirmModal } from "@/components/ui/modal"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Combobox } from "@/components/ui/combobox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { TogglePill } from "@/components/ui/toggle-pill"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -39,6 +35,9 @@ import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { VolumeNameLabel } from './resources/VolumeNameLabel'; +import { CreateNetworkDialog } from './resources/CreateNetworkDialog'; +import { useTableSort } from '@/hooks/useTableSort'; +import { SortableTableHead } from '@/components/ui/sortable-table'; import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); @@ -79,9 +78,6 @@ interface DockerVolume { isSencho: boolean; } -const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const; -type NetworkDriver = (typeof NETWORK_DRIVERS)[number]; - export interface DockerNetwork { Id: string; Name: string; @@ -301,6 +297,23 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { ); } +// Stable comparator maps for the resource tables (module scope so useTableSort +// does not re-sort on every render). Mirrors the Security Images sort standard. +const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = { + repo: (a, b) => (a.RepoTags?.[0] || '').localeCompare(b.RepoTags?.[0] || ''), + size: (a, b) => a.Size - b.Size, + status: (a, b) => Number(a.Containers > 0) - Number(b.Containers > 0), +}; +const VOLUME_COMPARATORS: Record<'name' | 'driver', (a: DockerVolume, b: DockerVolume) => number> = { + name: (a, b) => a.Name.localeCompare(b.Name), + driver: (a, b) => a.Driver.localeCompare(b.Driver), +}; +const NETWORK_COMPARATORS: Record<'name' | 'driver' | 'scope', (a: DockerNetwork, b: DockerNetwork) => number> = { + name: (a, b) => a.Name.localeCompare(b.Name), + driver: (a, b) => a.Driver.localeCompare(b.Driver), + scope: (a, b) => a.Scope.localeCompare(b.Scope), +}; + // ── Main Component ───────────────────────────────────────────────────────────── interface ResourcesViewProps { @@ -341,8 +354,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} // Network create/inspect state const [showCreateNetwork, setShowCreateNetwork] = useState(false); - const [createNetworkForm, setCreateNetworkForm] = useState<{ name: string; driver: NetworkDriver; subnet: string; gateway: string; internal: boolean; attachable: boolean }>({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); - const [isCreatingNetwork, setIsCreatingNetwork] = useState(false); const [inspectNetwork, setInspectNetwork] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null); const [inspectImageId, setInspectImageId] = useState(null); @@ -591,36 +602,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} } }; - const handleCreateNetwork = async () => { - setIsCreatingNetwork(true); - try { - const res = await apiFetch('/system/networks', { - method: 'POST', - body: JSON.stringify({ - name: createNetworkForm.name, - driver: createNetworkForm.driver, - subnet: createNetworkForm.subnet || undefined, - gateway: createNetworkForm.gateway || undefined, - internal: createNetworkForm.internal, - attachable: createNetworkForm.attachable, - }), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data?.error || 'Failed to create network'); - } - toast.success(`Network "${createNetworkForm.name}" created`); - setShowCreateNetwork(false); - setCreateNetworkForm({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); - await fetchAllData(); - } catch (error) { - const err = error as Record; - toast.error(String(err?.message || err?.error || 'Something went wrong.')); - } finally { - setIsCreatingNetwork(false); - } - }; - const handleInspectNetwork = async (id: string) => { setInspectLoadingId(id); try { @@ -650,6 +631,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true ); + // Sortable tables (standard sort behavior across the resource tables). + const imageSort = useTableSort(filteredImages, IMAGE_COMPARATORS, 'repo'); + const volumeSort = useTableSort(filteredVolumes, VOLUME_COMPARATORS, 'name'); + const networkSort = useTableSort(filteredNetworks, NETWORK_COMPARATORS, 'name'); + const handleFootprintFilter = (filter: ResourceFilter) => { setImageFilter(filter); setVolumeFilter(filter); @@ -858,23 +844,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} - {trivy.available && ( - - )} )} @@ -882,30 +851,49 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} {/* Images */} - i.managedStatus === 'managed').length, - unmanaged: images.filter(i => i.managedStatus !== 'managed').length, - }} - /> +
+ i.managedStatus === 'managed').length, + unmanaged: images.filter(i => i.managedStatus !== 'managed').length, + }} + /> + {trivy.available && ( + + )} +
ID - Repository:Tag - Size - Status + + + Action {isLoading ? : ( - {filteredImages.length === 0 ? ( + {imageSort.sorted.length === 0 ? ( No images found. - ) : filteredImages.map((img, i) => ( + ) : imageSort.sorted.map((img, i) => ( - Name - Driver + + Mountpoint Status Action @@ -1014,9 +1002,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} {isLoading ? : ( - {filteredVolumes.length === 0 ? ( + {volumeSort.sorted.length === 0 ? ( No volumes found. - ) : filteredVolumes.map((vol, i) => ( + ) : volumeSort.sorted.map((vol, i) => (
- {networkViewMode === 'list' && ( + {networkViewMode === 'list' ? ( n.managedStatus !== 'managed').length, }} /> + ) : ( + // Keep a left spacer so justify-between holds the toggle + + // Create Network group anchored on the right in topology mode. +
- STACK - HOST - UP - CPU - MEM + + SOURCE + PORT + + + CPU · 10m
@@ -184,7 +247,12 @@ export function StackHealthTable({ >