feat(resources): managed/unmanaged resource separation across Resources Hub

- Classify all Docker images, volumes, and networks as managed (Sencho
  stack), external (other Compose project), or unused/system via a new
  getClassifiedResources() method and GET /api/system/resources endpoint
- Add pruneManagedOnly() + getDiskUsageClassified() to DockerController
- Prune buttons now default to Sencho-managed scope; "All Docker" is
  hidden in a ⋮ dropdown with a distinct destructive confirm dialog
- Replace Reclaimable Space donut with interactive Docker Disk Footprint
  widget (stacked bar with clickable segments that filter resource tabs)
- Add managed/external filter toggles and classification badges per tab
- GET /api/stats now returns managed + unmanaged container counts; Home
  Dashboard Active Containers card subtitle shows "N managed · N external"
- Rename "Ghost Containers" tab/copy to "Unmanaged Containers" throughout
This commit is contained in:
SaelixCode
2026-03-22 03:17:54 -04:00
parent 740796b869
commit 5191737d53
5 changed files with 879 additions and 256 deletions
+50 -15
View File
@@ -1112,15 +1112,26 @@ app.post('/api/convert', async (req: Request, res: Response) => {
// Get all containers stats for dashboard
app.get('/api/stats', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
const [dockerController, knownStacks] = [
DockerController.getInstance(req.nodeId),
await FileSystemService.getInstance(req.nodeId).getStacks(),
];
const allContainers = await dockerController.getAllContainers();
const knownSet = new Set(knownStacks);
const active = containers.length;
const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length;
const active = allContainers.filter((c: any) => c.State === 'running').length;
const exited = allContainers.filter((c: any) => c.State === 'exited').length;
const total = allContainers.length;
const managed = allContainers.filter((c: any) => {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
return project && knownSet.has(project) && c.State === 'running';
}).length;
const unmanaged = allContainers.filter((c: any) => {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
return project && !knownSet.has(project) && c.State === 'running';
}).length;
res.json({ active, exited, total, inactive: total - active - exited });
res.json({ active, managed, unmanaged, exited, total });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
@@ -1624,13 +1635,24 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
try {
const { target } = req.body; // 'containers', 'images', 'networks', 'volumes'
const { target, scope } = req.body as { target: string; scope?: string };
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
return res.status(400).json({ error: 'Invalid prune target' });
}
const dockerController = DockerController.getInstance(req.nodeId);
const result = await dockerController.pruneSystem(target);
const pruneScope = scope === 'managed' ? 'managed' : 'all';
let result: { success: boolean; reclaimedBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
result = await dockerController.pruneManagedOnly(
target as 'images' | 'volumes' | 'networks',
knownStacks
);
} else {
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
}
res.json({ message: 'Prune completed', ...result });
} catch (error: any) {
@@ -1641,8 +1663,8 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => {
app.get('/api/system/docker-df', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const df = await dockerController.getDiskUsage();
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks);
res.json(df);
} catch (error) {
console.error('Failed to fetch docker disk usage:', error);
@@ -1650,10 +1672,23 @@ app.get('/api/system/docker-df', async (req: Request, res: Response) => {
}
});
// Single endpoint returning classified images, volumes, and networks in one call
app.get('/api/system/resources', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(result);
} catch (error) {
console.error('Failed to fetch classified resources:', error);
res.status(500).json({ error: 'Failed to fetch resources' });
}
});
// Keep legacy endpoints for backward compat with remote proxy routing
app.get('/api/system/images', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const images = await dockerController.getImages();
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(images);
} catch (error) {
console.error('Failed to fetch images:', error);
@@ -1663,8 +1698,8 @@ app.get('/api/system/images', async (req: Request, res: Response) => {
app.get('/api/system/volumes', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const volumes = await dockerController.getVolumes();
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(volumes);
} catch (error) {
console.error('Failed to fetch volumes:', error);
@@ -1674,8 +1709,8 @@ app.get('/api/system/volumes', async (req: Request, res: Response) => {
app.get('/api/system/networks', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const networks = await dockerController.getNetworks();
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(networks);
} catch (error) {
console.error('Failed to fetch networks:', error);
+192
View File
@@ -11,6 +11,32 @@ import { NodeRegistry } from './NodeRegistry';
const execAsync = promisify(exec);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
export interface ClassifiedImage {
Id: string;
RepoTags: string[];
Size: number;
Containers: number;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'unused';
}
export interface ClassifiedVolume {
Name: string;
Driver: string;
Mountpoint: string;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged';
}
export interface ClassifiedNetwork {
Id: string;
Name: string;
Driver: string;
Scope: string;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'system';
}
class DockerController {
private docker: Docker;
private nodeId: number;
@@ -112,6 +138,172 @@ class DockerController {
return this.validateApiData<any[]>(data);
}
public async getClassifiedResources(knownStackNames: string[]): Promise<{
images: ClassifiedImage[];
volumes: ClassifiedVolume[];
networks: ClassifiedNetwork[];
}> {
const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
const knownSet = new Set(knownStackNames);
const [rawImages, rawVolumeData, rawNetworks, allContainers] = await Promise.all([
this.docker.listImages({ all: false }),
this.docker.listVolumes(),
this.docker.listNetworks(),
this.docker.listContainers({ all: true }),
]);
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
// Build imageId → project mapping from container labels
const imageToProject = new Map<string, string>();
for (const c of allContainers as any[]) {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
if (project && c.ImageID) imageToProject.set(c.ImageID, project);
}
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
const project = imageToProject.get(img.Id) ?? null;
const managedStatus: ClassifiedImage['managedStatus'] =
img.Containers === 0 ? 'unused' :
project && knownSet.has(project) ? 'managed' : 'unmanaged';
return {
Id: img.Id,
RepoTags: img.RepoTags ?? [],
Size: img.Size ?? 0,
Containers: img.Containers ?? 0,
managedBy: managedStatus === 'managed' ? project : null,
managedStatus,
};
});
const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => {
const project: string | undefined = vol.Labels?.['com.docker.compose.project'];
const managedStatus: ClassifiedVolume['managedStatus'] =
project && knownSet.has(project) ? 'managed' : 'unmanaged';
return {
Name: vol.Name,
Driver: vol.Driver,
Mountpoint: vol.Mountpoint,
managedBy: managedStatus === 'managed' ? project! : null,
managedStatus,
};
});
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
if (SYSTEM_NETWORKS.has(net.Name)) {
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
}
const project: string | undefined = net.Labels?.['com.docker.compose.project'];
const managedStatus: ClassifiedNetwork['managedStatus'] =
project && knownSet.has(project) ? 'managed' : 'unmanaged';
return {
Id: net.Id,
Name: net.Name,
Driver: net.Driver,
Scope: net.Scope,
managedBy: managedStatus === 'managed' ? project! : null,
managedStatus,
};
});
return { images, volumes, networks };
}
public async pruneManagedOnly(
target: 'images' | 'volumes' | 'networks',
knownStackNames: string[]
): Promise<{ success: boolean; reclaimedBytes: number }> {
const knownSet = new Set(knownStackNames);
let reclaimedBytes = 0;
if (target === 'volumes') {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const prunable = rawVolumes.filter((v: any) => {
const project: string | undefined = v.Labels?.['com.docker.compose.project'];
return project && knownSet.has(project) && (v.UsageData?.RefCount ?? 1) === 0;
});
for (const vol of prunable) {
try {
await this.docker.getVolume(vol.Name).remove({ force: true });
reclaimedBytes += vol.UsageData?.Size ?? 0;
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e);
}
}
} else if (target === 'networks') {
const rawNetworks = await this.docker.listNetworks();
const prunable = (rawNetworks as any[]).filter((n: any) => {
const project: string | undefined = n.Labels?.['com.docker.compose.project'];
return project && knownSet.has(project);
});
for (const net of prunable) {
try {
await this.docker.getNetwork(net.Id).remove({ force: true });
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e);
}
}
} else if (target === 'images') {
const allContainers = await this.docker.listContainers({ all: true });
const unmanagedImageIds = new Set<string>();
for (const c of allContainers as any[]) {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
if (!project || !knownSet.has(project)) unmanagedImageIds.add(c.ImageID);
}
const rawImages = await this.docker.listImages({ all: false });
const prunable = (rawImages as any[]).filter((img: any) =>
img.Containers === 0 && !unmanagedImageIds.has(img.Id)
);
for (const img of prunable) {
try {
await this.docker.getImage(img.Id).remove({ force: true });
reclaimedBytes += img.Size ?? 0;
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e);
}
}
}
return { success: true, reclaimedBytes };
}
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
reclaimableImages: number;
reclaimableContainers: number;
reclaimableVolumes: number;
managedImageBytes: number;
unmanagedImageBytes: number;
managedVolumeBytes: number;
unmanagedVolumeBytes: number;
}> {
const [base, classified] = await Promise.all([
this.getDiskUsage(),
this.getClassifiedResources(knownStackNames),
]);
const managedImageBytes = classified.images
.filter(i => i.managedStatus === 'managed')
.reduce((acc, i) => acc + i.Size, 0);
const unmanagedImageBytes = classified.images
.filter(i => i.managedStatus === 'unmanaged')
.reduce((acc, i) => acc + i.Size, 0);
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const knownSet = new Set(knownStackNames);
const managedVolumeBytes = rawVolumes
.filter((v: any) => knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
const unmanagedVolumeBytes = rawVolumes
.filter((v: any) => !knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes };
}
public async removeImage(id: string) {
const image = this.docker.getImage(id);
await image.remove({ force: true });