mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +00:00
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:
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user