Merge pull request #57 from AnsoCode/feat/resources-managed-unmanaged-separation

feat(resources): managed/unmanaged resource separation across Resources Hub
This commit is contained in:
Anso
2026-03-22 03:24:35 -04:00
committed by GitHub
5 changed files with 880 additions and 257 deletions
+9
View File
@@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Resources Hub — Managed/Unmanaged Separation:** All Docker resources (images, volumes, networks) are now classified as `managed` (belonging to a Sencho stack), `external` (belonging to another Compose project), or `unused`/`system`. Classification is exposed via a new `GET /api/system/resources` endpoint that makes 4 parallel Docker API calls once and returns all three resource types in a single round trip.
- **Docker Disk Footprint widget:** Replaces the Reclaimable Space donut chart with an interactive horizontal stacked bar showing Sencho Managed vs External Projects vs Reclaimable bytes. Clicking a segment filters the Images and Volumes tabs simultaneously.
- **Scoped prune operations:** Quick Clean buttons now target Sencho-managed resources only by default ("Sencho only" label). An "All Docker" option is hidden in a `⋮` dropdown per prune target, with a distinct destructive confirmation dialog. The `POST /api/system/prune/system` endpoint accepts an optional `scope: 'managed' | 'all'` body parameter; a new `pruneManagedOnly()` method on `DockerController` filters by `com.docker.compose.project` label before removing resources.
- **Managed/External filter toggles:** Each resource tab (Images, Volumes, Networks) has a segmented pill control to show All / Managed / External resources with live counts.
- **Classification badges:** Each resource row displays a green "stack-name" badge for managed resources, an orange "External" badge for external, and a muted "System" badge for Docker-native networks (`bridge`, `host`, `none`). System networks have their delete button disabled.
- **Active Containers split stat:** `GET /api/stats` now returns `managed` and `unmanaged` container counts in addition to `active`/`exited`/`total`. The Home Dashboard "Active Containers" card subtitle shows "N managed · N external".
- **Renamed "Ghost Containers" → "Unmanaged Containers":** Tab label, dialog titles, toast messages, and empty-state copy updated throughout `ResourcesView`.
### Security
- **Fixed:** `POST /api/system/console-token` was missing `authMiddleware` — any unauthenticated client could generate console session tokens. Fixed by adding `authMiddleware` to the route.
- **Fixed:** Remote node `api_url` was accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL, and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`.
+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 });
+7 -4
View File
@@ -13,9 +13,10 @@ import { Label } from './ui/label';
interface Stats {
active: number;
managed: number;
unmanaged: number;
exited: number;
total: number;
inactive: number;
}
interface MetricPoint {
@@ -66,13 +67,13 @@ export default function HomeDashboard() {
const [convertedYaml, setConvertedYaml] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newStackName, setNewStackName] = useState('');
const [stats, setStats] = useState<Stats>({ active: 0, exited: 0, total: 0, inactive: 0 });
const [stats, setStats] = useState<Stats>({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
// Fetch container stats - re-runs when active node changes so stale data is cleared immediately
useEffect(() => {
setStats({ active: 0, exited: 0, total: 0, inactive: 0 });
setStats({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
const fetchStats = async () => {
try {
const res = await apiFetch('/stats');
@@ -221,7 +222,9 @@ export default function HomeDashboard() {
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-500">{stats.active}</div>
<p className="text-xs text-muted-foreground mt-1">Currently running</p>
<p className="text-xs text-muted-foreground mt-1">
{stats.managed} managed · {stats.unmanaged} external
</p>
</CardContent>
</Card>
File diff suppressed because it is too large Load Diff