mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
+50
-15
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user