mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(resources): harden Resource Explorer with auth, validation, design, and UX fixes (#527)
- Sanitize error messages in all delete/prune/create/inspect endpoints to prevent Docker internals from leaking to the frontend - Add CIDR, IPv4, and Docker resource ID input validation - Add requirePaid gate to network topology endpoint - Add invalidateNodeCaches after image/volume/network mutations - Fix design system violations: card borders, destructive button variant, visible DialogDescription, overflow-auto replaced with ScrollArea, hardcoded Tailwind colors replaced with tokens - Gate purge button behind isAdmin to prevent silent 403s - Fix shared inspect loading state to be per-network-row - Parse error response bodies for meaningful toast messages - Add clipboard API fallback for non-HTTPS contexts - Render Options section in network inspect sheet - Add operational and diagnostic logging for resource operations - Extend validation and DockerController test suites - Update docs with Options field in network inspect
This commit is contained in:
@@ -555,3 +555,52 @@ describe('DockerController - inspectNetwork edge cases', () => {
|
||||
await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon');
|
||||
});
|
||||
});
|
||||
|
||||
// --- createNetwork validation --------------------------------------------------
|
||||
|
||||
describe('createNetwork', () => {
|
||||
it('rejects empty network name', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('rejects names with invalid characters', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: 'net work' })).rejects.toThrow('Invalid network name');
|
||||
await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
|
||||
await expect(dc.createNetwork({ Name: 'net;rm' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('accepts valid network names and passes through to Docker', async () => {
|
||||
mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-id' });
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.createNetwork({ Name: 'my-network_v2' });
|
||||
expect(result.id).toBe('new-net-id');
|
||||
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-network_v2' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- removeContainers mixed results -------------------------------------------
|
||||
|
||||
describe('removeContainers', () => {
|
||||
it('returns mixed results when some removals fail', async () => {
|
||||
mockDocker.getContainer.mockImplementation((id: string) => {
|
||||
if (id === 'fail-id') {
|
||||
return { remove: vi.fn().mockRejectedValue(new Error('no such container')) };
|
||||
}
|
||||
return { remove: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const results = await dc.removeContainers(['ok-id-000000', 'fail-id']);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ id: 'ok-id-000000', success: true });
|
||||
expect(results[1]).toMatchObject({ id: 'fail-id', success: false });
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const results = await dc.removeContainers([]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
|
||||
import {
|
||||
isValidStackName, isValidRemoteUrl, isPathWithinBase,
|
||||
isValidCidr, isValidIPv4, isValidDockerResourceId,
|
||||
} from '../utils/validation';
|
||||
|
||||
// ─── isValidStackName ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,3 +125,103 @@ describe('isPathWithinBase', () => {
|
||||
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidCidr ---------------------------------------------------------------
|
||||
|
||||
describe('isValidCidr', () => {
|
||||
it('accepts valid CIDR notation', () => {
|
||||
expect(isValidCidr('10.0.0.0/24')).toBe(true);
|
||||
expect(isValidCidr('172.16.0.0/16')).toBe(true);
|
||||
expect(isValidCidr('192.168.1.0/28')).toBe(true);
|
||||
expect(isValidCidr('0.0.0.0/0')).toBe(true);
|
||||
expect(isValidCidr('255.255.255.255/32')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing prefix', () => {
|
||||
expect(isValidCidr('10.0.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects prefix out of range', () => {
|
||||
expect(isValidCidr('10.0.0.0/33')).toBe(false);
|
||||
expect(isValidCidr('10.0.0.0/99')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects octet out of range', () => {
|
||||
expect(isValidCidr('256.0.0.0/24')).toBe(false);
|
||||
expect(isValidCidr('10.999.0.0/16')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage input', () => {
|
||||
expect(isValidCidr('')).toBe(false);
|
||||
expect(isValidCidr('not-a-cidr')).toBe(false);
|
||||
expect(isValidCidr('10.0.0/24')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidIPv4 ---------------------------------------------------------------
|
||||
|
||||
describe('isValidIPv4', () => {
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
expect(isValidIPv4('10.0.0.1')).toBe(true);
|
||||
expect(isValidIPv4('192.168.1.1')).toBe(true);
|
||||
expect(isValidIPv4('0.0.0.0')).toBe(true);
|
||||
expect(isValidIPv4('255.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects incomplete addresses', () => {
|
||||
expect(isValidIPv4('10.0.0')).toBe(false);
|
||||
expect(isValidIPv4('10')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects octet out of range', () => {
|
||||
expect(isValidIPv4('256.1.2.3')).toBe(false);
|
||||
expect(isValidIPv4('10.0.0.999')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects CIDR notation (use isValidCidr instead)', () => {
|
||||
expect(isValidIPv4('10.0.0.1/24')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage input', () => {
|
||||
expect(isValidIPv4('')).toBe(false);
|
||||
expect(isValidIPv4('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidDockerResourceId ---------------------------------------------------
|
||||
|
||||
describe('isValidDockerResourceId', () => {
|
||||
it('accepts 12-character hex IDs (short form)', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3d4e5f6')).toBe(true);
|
||||
expect(isValidDockerResourceId('AABB00112233')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts 64-character hex IDs (full SHA256)', () => {
|
||||
expect(isValidDockerResourceId('a'.repeat(64))).toBe(true);
|
||||
expect(isValidDockerResourceId('abcdef0123456789'.repeat(4))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts mixed-case hex of valid lengths', () => {
|
||||
expect(isValidDockerResourceId('aAbBcCdDeEfF')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects IDs shorter than 12 characters', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3d4e5f')).toBe(false);
|
||||
expect(isValidDockerResourceId('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects IDs longer than 64 characters', () => {
|
||||
expect(isValidDockerResourceId('a'.repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-hex characters', () => {
|
||||
expect(isValidDockerResourceId('g1b2c3d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('hello-world!')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects IDs with slashes, dots, or spaces', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3/d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('a1b2c3.d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('a1b2c3 d4e5f6')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+47
-19
@@ -62,7 +62,7 @@ import { getErrorMessage } from './utils/errors';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import semver from 'semver';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
|
||||
@@ -5414,8 +5414,15 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
|
||||
if (!Array.isArray(containerIds)) {
|
||||
return res.status(400).json({ error: 'containerIds must be an array' });
|
||||
}
|
||||
const invalidIds = containerIds.filter((id: unknown) => typeof id !== 'string' || !isValidDockerResourceId(id));
|
||||
if (invalidIds.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more container IDs have an invalid format' });
|
||||
}
|
||||
console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const results = await dockerController.removeContainers(containerIds);
|
||||
const succeeded = results.filter((r: { success: boolean }) => r.success).length;
|
||||
console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
@@ -5432,8 +5439,9 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
}
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
|
||||
let result: { success: boolean; reclaimedBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
@@ -5446,13 +5454,14 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
|
||||
}
|
||||
|
||||
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);
|
||||
if (target === 'containers') {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
}
|
||||
res.json({ message: 'Prune completed', ...result });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('System prune error:', error);
|
||||
res.status(500).json({ error: 'System prune failed', details: error.message });
|
||||
res.status(500).json({ error: 'System prune failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5518,12 +5527,17 @@ app.post('/api/system/images/delete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid image ID format' });
|
||||
}
|
||||
console.log(`[Resources] Delete image: ${id.substring(0, 12)}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeImage(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Image deleted' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete image:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete image' });
|
||||
res.status(500).json({ error: 'Failed to delete image' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5531,13 +5545,15 @@ app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' });
|
||||
console.log(`[Resources] Delete volume: ${id}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeVolume(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Volume deleted' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete volume:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete volume' });
|
||||
res.status(500).json({ error: 'Failed to delete volume' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5546,26 +5562,32 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid network ID format' });
|
||||
}
|
||||
console.log(`[Resources] Delete network: ${id.substring(0, 12)}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeNetwork(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Network deleted' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete network' });
|
||||
res.status(500).json({ error: 'Failed to delete network' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const includeSystem = req.query.includeSystem === 'true';
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const topology = await dockerController.getTopologyData(knownStacks, includeSystem);
|
||||
if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem });
|
||||
res.json(topology);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to fetch network topology';
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
res.status(500).json({ error: msg });
|
||||
res.status(500).json({ error: 'Failed to fetch network topology' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5579,10 +5601,9 @@ app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to inspect network:', error);
|
||||
const err = error as Record<string, unknown>;
|
||||
const status = (typeof err.statusCode === 'number' ? err.statusCode : null)
|
||||
|| (error instanceof Error && error.message.includes('404') ? 404 : 500);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to inspect network';
|
||||
res.status(status).json({ error: msg });
|
||||
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|
||||
|| (error instanceof Error && error.message.includes('404'));
|
||||
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5600,6 +5621,8 @@ app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
options.Driver = driver;
|
||||
}
|
||||
if (subnet || gateway) {
|
||||
if (subnet && !isValidCidr(subnet)) return res.status(400).json({ error: 'Invalid subnet CIDR notation (e.g. 172.20.0.0/16)' });
|
||||
if (gateway && !isValidIPv4(gateway)) return res.status(400).json({ error: 'Invalid gateway IP address (e.g. 172.20.0.1)' });
|
||||
options.IPAM = { Config: [{}] };
|
||||
if (subnet) options.IPAM.Config[0].Subnet = subnet;
|
||||
if (gateway) options.IPAM.Config[0].Gateway = gateway;
|
||||
@@ -5610,11 +5633,16 @@ app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const network = await dockerController.createNetwork(options);
|
||||
console.log(`[Resources] Network created: ${name}`);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.status(201).json({ success: true, message: 'Network created', id: network.id });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create network';
|
||||
console.error('Failed to create network:', error);
|
||||
res.status(500).json({ error: msg });
|
||||
const msg = getErrorMessage(error, '');
|
||||
const safePatterns = ['already exists', 'name is invalid', 'invalid network name'];
|
||||
const lowerMsg = msg.toLowerCase();
|
||||
const isSafe = safePatterns.some(p => lowerMsg.includes(p));
|
||||
res.status(isSafe ? 409 : 500).json({ error: isSafe ? msg : 'Failed to create network' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as yaml from 'yaml';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { CacheService } from './CacheService';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
@@ -205,6 +206,8 @@ class DockerController {
|
||||
volumes: ClassifiedVolume[];
|
||||
networks: ClassifiedNetwork[];
|
||||
}> {
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = debug ? Date.now() : 0;
|
||||
const knownSet = new Set(knownStackNames);
|
||||
|
||||
const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([
|
||||
@@ -274,6 +277,10 @@ class DockerController {
|
||||
};
|
||||
});
|
||||
|
||||
if (debug) console.debug('[Resources:debug] Classification completed', {
|
||||
ms: Date.now() - t0, images: images.length, volumes: volumes.length, networks: networks.length,
|
||||
});
|
||||
|
||||
return { images, volumes, networks };
|
||||
}
|
||||
|
||||
@@ -926,6 +933,7 @@ class DockerController {
|
||||
}
|
||||
|
||||
public async removeContainers(containerIds: string[]) {
|
||||
if (isDebugEnabled()) console.debug('[Resources:debug] removeContainers', { count: containerIds.length });
|
||||
const results = [];
|
||||
for (const id of containerIds) {
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,39 @@ export function isValidRemoteUrl(
|
||||
return { valid: true, url };
|
||||
}
|
||||
|
||||
/** Returns true when all four captured octet strings are in 0-255 range. */
|
||||
function octetsInRange(a: string, b: string, c: string, d: string): boolean {
|
||||
return [a, b, c, d].map(Number).every(o => o >= 0 && o <= 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an IPv4 CIDR notation string (e.g. `10.0.0.0/24`).
|
||||
* Checks octet ranges (0-255) and prefix length (0-32).
|
||||
*/
|
||||
export function isValidCidr(value: string): boolean {
|
||||
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(value);
|
||||
if (!match) return false;
|
||||
return octetsInRange(match[1], match[2], match[3], match[4]) && Number(match[5]) <= 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a plain IPv4 address (e.g. `192.168.1.1`).
|
||||
* Rejects CIDR notation; use `isValidCidr` for that.
|
||||
*/
|
||||
export function isValidIPv4(value: string): boolean {
|
||||
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
|
||||
if (!match) return false;
|
||||
return octetsInRange(match[1], match[2], match[3], match[4]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Docker resource ID (hex string, 12-64 characters).
|
||||
* Covers both short IDs (12 chars) and full SHA256 IDs (64 chars).
|
||||
*/
|
||||
export function isValidDockerResourceId(id: string): boolean {
|
||||
return /^[a-f0-9]{12,64}$/i.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
|
||||
@@ -96,6 +96,7 @@ Click the eye icon on any network row to open a detail panel showing:
|
||||
|
||||
- **Overview** - ID, driver, scope, creation date, internal/attachable flags
|
||||
- **IPAM configuration** - Subnet, gateway, and IP range
|
||||
- **Options** - Driver-specific options applied to the network (when present)
|
||||
- **Connected containers** - Container name, IPv4 address, and MAC address
|
||||
- **Labels** - All Docker labels applied to the network
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from "@/components/ui/tabs";
|
||||
@@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
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";
|
||||
@@ -115,7 +116,6 @@ interface FootprintWidgetProps {
|
||||
|
||||
function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) {
|
||||
const [animated, setAnimated] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const managedBytes = usage.managedImageBytes + usage.managedVolumeBytes;
|
||||
const unmanagedBytes = usage.unmanagedImageBytes + usage.unmanagedVolumeBytes;
|
||||
@@ -146,7 +146,7 @@ function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-4 animate-in fade-in-0 duration-300">
|
||||
<div className="space-y-4 animate-in fade-in-0 duration-300">
|
||||
{/* Stacked bar */}
|
||||
<div className="relative flex h-4 w-full rounded-full overflow-hidden bg-muted gap-px">
|
||||
{segments.map((seg, i) =>
|
||||
@@ -287,8 +287,8 @@ interface PruneButtonProps {
|
||||
function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: PruneButtonProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'group flex flex-col rounded-lg border bg-card overflow-hidden',
|
||||
'transition-shadow duration-200 hover:shadow-md',
|
||||
'group flex flex-col rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden',
|
||||
'transition-colors duration-200 hover:border-t-card-border-hover',
|
||||
)}>
|
||||
<button
|
||||
onClick={onManaged}
|
||||
@@ -374,7 +374,7 @@ export default function ResourcesView() {
|
||||
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<NetworkInspectData | null>(null);
|
||||
const [isInspectLoading, setIsInspectLoading] = useState(false);
|
||||
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
|
||||
|
||||
// Unmanaged container state
|
||||
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
|
||||
@@ -445,11 +445,15 @@ export default function ResourcesView() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: confirmDelete.id })
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || `Failed to delete ${confirmDelete.type.slice(0, -1)}`);
|
||||
}
|
||||
toast.success(`Deleted ${confirmDelete.type.slice(0, -1)}`);
|
||||
await fetchAllData();
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`);
|
||||
} catch (error) {
|
||||
const err = error as Record<string, unknown>;
|
||||
toast.error(String(err?.message || `Failed to delete ${confirmDelete.type.slice(0, -1)}`));
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setIsActioning(false);
|
||||
@@ -474,12 +478,16 @@ export default function ResourcesView() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ containerIds: selectedOrphans })
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to purge selected containers');
|
||||
}
|
||||
toast.success(`Purged ${selectedOrphans.length} unmanaged container(s)`);
|
||||
setBulkPurgeConfirm(false);
|
||||
await fetchAllData();
|
||||
} catch {
|
||||
toast.error('Failed to purge selected containers.');
|
||||
} catch (error) {
|
||||
const err = error as Record<string, unknown>;
|
||||
toast.error(String(err?.message || 'Failed to purge selected containers.'));
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setIsActioning(false);
|
||||
@@ -517,7 +525,7 @@ export default function ResourcesView() {
|
||||
};
|
||||
|
||||
const handleInspectNetwork = async (id: string) => {
|
||||
setIsInspectLoading(true);
|
||||
setInspectLoadingId(id);
|
||||
try {
|
||||
const res = await apiFetch(`/system/networks/${id}`);
|
||||
if (!res.ok) throw new Error('Failed to inspect network');
|
||||
@@ -527,7 +535,7 @@ export default function ResourcesView() {
|
||||
const err = error as Record<string, unknown>;
|
||||
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
|
||||
} finally {
|
||||
setIsInspectLoading(false);
|
||||
setInspectLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -566,7 +574,7 @@ export default function ResourcesView() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
{/* Disk Footprint */}
|
||||
<Card className="col-span-1 border-border shadow-card-bevel animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
||||
<Card className="col-span-1 border-card-border border-t-card-border-top shadow-card-bevel transition-colors hover:border-t-card-border-hover animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
||||
Docker Disk Footprint
|
||||
@@ -592,7 +600,7 @@ export default function ResourcesView() {
|
||||
</Card>
|
||||
|
||||
{/* Quick Clean */}
|
||||
{isAdmin && <Card className="col-span-1 md:col-span-2 border-border shadow-card-bevel flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
{isAdmin && <Card className="col-span-1 md:col-span-2 border-card-border border-t-card-border-top shadow-card-bevel transition-colors hover:border-t-card-border-hover flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
||||
Quick Clean
|
||||
@@ -608,7 +616,7 @@ export default function ResourcesView() {
|
||||
target="images"
|
||||
icon={<PackageMinus className="w-6 h-6" />}
|
||||
label="Prune Unused Images"
|
||||
accentClass="text-blue-500"
|
||||
accentClass="text-info"
|
||||
onManaged={() => setConfirmPrune({ target: 'images', scope: 'managed' })}
|
||||
onAll={() => setConfirmPrune({ target: 'images', scope: 'all' })}
|
||||
/>
|
||||
@@ -616,7 +624,7 @@ export default function ResourcesView() {
|
||||
target="volumes"
|
||||
icon={<HardDrive className="w-6 h-6" />}
|
||||
label="Prune Unused Volumes"
|
||||
accentClass="text-purple-500"
|
||||
accentClass="text-brand"
|
||||
onManaged={() => setConfirmPrune({ target: 'volumes', scope: 'managed' })}
|
||||
onAll={() => setConfirmPrune({ target: 'volumes', scope: 'all' })}
|
||||
/>
|
||||
@@ -670,7 +678,7 @@ export default function ResourcesView() {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-background relative text-sm">
|
||||
<ScrollArea className="flex-1 bg-background relative text-sm">
|
||||
|
||||
{/* Images */}
|
||||
<TabsContent value="images" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
||||
@@ -876,10 +884,10 @@ export default function ResourcesView() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-foreground transition-colors"
|
||||
disabled={isInspectLoading}
|
||||
disabled={inspectLoadingId !== null}
|
||||
onClick={() => handleInspectNetwork(net.Id)}
|
||||
>
|
||||
{isInspectLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
{inspectLoadingId === net.Id ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
@@ -912,16 +920,16 @@ export default function ResourcesView() {
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">Select all</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
className="h-7 text-xs gap-1.5 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setBulkPurgeConfirm(true)}
|
||||
disabled={selectedOrphans.length === 0 || isActioning}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
{isActioning ? 'Purging...' : `Purge Selected (${selectedOrphans.length})`}
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
|
||||
{totalOrphansCount === 0 ? (
|
||||
@@ -983,7 +991,7 @@ export default function ResourcesView() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Dialogs ── */}
|
||||
@@ -1068,7 +1076,7 @@ export default function ResourcesView() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Network</DialogTitle>
|
||||
<DialogDescription>Create a new Docker network for inter-container communication.</DialogDescription>
|
||||
<DialogDescription className="sr-only">Create a new Docker network for inter-container communication.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
@@ -1144,7 +1152,8 @@ export default function ResourcesView() {
|
||||
|
||||
{/* Network Inspect Sheet */}
|
||||
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
|
||||
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
||||
<SheetContent className="sm:max-w-lg">
|
||||
<ScrollArea className="h-full">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Network className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
@@ -1152,7 +1161,7 @@ export default function ResourcesView() {
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{inspectNetwork && (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="space-y-6 mt-6 pb-6">
|
||||
{/* Overview */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Overview</h4>
|
||||
@@ -1163,7 +1172,7 @@ export default function ResourcesView() {
|
||||
{inspectNetwork.Id.substring(0, 12)}
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => { navigator.clipboard.writeText(inspectNetwork.Id); toast.success('ID copied'); }}
|
||||
onClick={async () => { try { await navigator.clipboard.writeText(inspectNetwork.Id); toast.success('ID copied'); } catch { toast.error('Copy failed (HTTPS required)'); } }}
|
||||
>
|
||||
<Copy className="w-3 h-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -1223,6 +1232,25 @@ export default function ResourcesView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{inspectNetwork.Options && Object.keys(inspectNetwork.Options).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Options</h4>
|
||||
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{Object.entries(inspectNetwork.Options).map(([key, val]) => (
|
||||
<TableRow key={key} className="hover:bg-muted/30">
|
||||
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
|
||||
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected Containers */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
@@ -1241,11 +1269,11 @@ export default function ResourcesView() {
|
||||
<div className="grid grid-cols-2 gap-2 pl-5.5">
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">IPv4</span>
|
||||
<p className="font-mono text-xs tabular-nums">{container.IPv4Address || '—'}</p>
|
||||
<p className="font-mono text-xs tabular-nums">{container.IPv4Address || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">MAC</span>
|
||||
<p className="font-mono text-xs tabular-nums">{container.MacAddress || '—'}</p>
|
||||
<p className="font-mono text-xs tabular-nums">{container.MacAddress || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1274,6 +1302,7 @@ export default function ResourcesView() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user