feat: sortable resource tables and richer dashboard stack-health columns (#1498)

Make the Resources Images, Volumes, and Networks tables sortable with a
shared useTableSort hook and SortableTableHead, move the Images scan-history
control into the Images tab header, and keep the network List/Topology toggle
anchored with Create Network visible in both modes.

Rework the dashboard Stack health table: drop the redundant Host column, add
sortable Stack/Up/CPU/Mem headers, and add Source (local/git) and Port columns.
The status endpoint now labels each stack with its git/local source, computed
outside the cache so linking changes show immediately.

Extract a reusable CreateNetworkDialog and add a create-network action to the
stack-detail Networking tab.
This commit is contained in:
Anso
2026-06-28 05:06:04 -04:00
committed by GitHub
parent cf0db36e78
commit 60536aa614
11 changed files with 512 additions and 190 deletions
@@ -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 ────────────────────────────────────────────
+17 -1
View File
@@ -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<string>();
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<string, BulkStackInfo & { source: 'local' | 'git' }> = {};
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' });