m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-14 16:13:48 -05:00

feat: show which containers use a specific image

This commit is contained in:
Sergio
2025-05-10 09:28:28 +03:00
parent eaf2cd7881
commit ddd514ffa0
9 changed files with 83 additions and 27 deletions

View File

@@ -107,8 +107,9 @@ pub async fn get_updates(
// Complete in_use field // Complete in_use field
images.iter_mut().for_each(|image| { images.iter_mut().for_each(|image| {
if in_use_images.contains(&image.reference) { match in_use_images.get(&image.reference) {
image.in_use = true Some(images) => image.used_by = images.clone(),
None => {}
} }
}); });

View File

@@ -1,6 +1,7 @@
use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker}; use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker};
use futures::future::join_all; use futures::future::join_all;
use rustc_hash::FxHashMap;
use crate::{error, structs::image::Image, Context}; use crate::{error, structs::image::Image, Context};
@@ -95,7 +96,7 @@ pub async fn get_images_from_docker_daemon(
local_images local_images
} }
pub async fn get_in_use_images(ctx: &Context) -> Vec<String> { pub async fn get_in_use_images(ctx: &Context) -> FxHashMap<String, Vec<String>> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref()); let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let containers = match client let containers = match client
@@ -111,17 +112,40 @@ pub async fn get_in_use_images(ctx: &Context) -> Vec<String> {
} }
}; };
let mut result: FxHashMap<String, Vec<String>> = FxHashMap::default();
containers containers
.iter() .iter()
.filter_map(|container| match &container.image { .filter(|container| container.image.is_some())
Some(image) => Some({ .for_each(|container| {
let reference = match &container.image {
Some(image) => {
if image.contains(":") { if image.contains(":") {
image.clone() image.clone()
} else { } else {
format!("{image}:latest") format!("{image}:latest")
} }
}), }
None => None, None => unreachable!(),
}) };
let mut names: Vec<String> = container
.names
.as_ref()
.map(|names| {
names
.iter()
.map(|name| name.trim_start_matches('/').to_owned())
.collect() .collect()
})
.unwrap_or(Vec::new());
match result.get_mut(&reference) {
Some(containers) => containers.append(&mut names),
None => {
let _ = result.insert(reference, names);
}
}
});
result.clone()
} }

View File

@@ -38,7 +38,7 @@ pub struct Image {
pub url: Option<String>, pub url: Option<String>,
pub digest_info: Option<DigestInfo>, pub digest_info: Option<DigestInfo>,
pub version_info: Option<VersionInfo>, pub version_info: Option<VersionInfo>,
pub in_use: bool, pub used_by: Vec<String>,
pub error: Option<String>, pub error: Option<String>,
pub time_ms: u32, pub time_ms: u32,
} }
@@ -222,7 +222,7 @@ impl Image {
}, },
time: self.time_ms, time: self.time_ms,
server: None, server: None,
in_use: self.in_use, used_by: self.used_by.clone(),
status: has_update, status: has_update,
} }
} }

View File

@@ -11,7 +11,7 @@ pub struct Update {
pub result: UpdateResult, pub result: UpdateResult,
pub time: u32, pub time: u32,
pub server: Option<String>, pub server: Option<String>,
pub in_use: bool, pub used_by: Vec<String>,
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
pub status: Status, pub status: Status,
} }

View File

@@ -123,7 +123,7 @@ function App() {
<Server name={server} key={server}> <Server name={server} key={server}>
{images {images
.filter((image) => .filter((image) =>
filters.onlyInUse ? !!image.in_use : true, filters.onlyInUse ? image.used_by.length > 0 : true,
) )
.filter((image) => .filter((image) =>
filters.registries.length == 0 filters.registries.length == 0

View File

@@ -4,6 +4,9 @@ import {
DialogBackdrop, DialogBackdrop,
DialogPanel, DialogPanel,
DialogTitle, DialogTitle,
Disclosure,
DisclosureButton,
DisclosurePanel,
} from "@headlessui/react"; } from "@headlessui/react";
import { WithTooltip } from "./ui/Tooltip"; import { WithTooltip } from "./ui/Tooltip";
import type { Image } from "../types"; import type { Image } from "../types";
@@ -11,15 +14,17 @@ import { theme } from "../theme";
import { CodeBlock } from "./CodeBlock"; import { CodeBlock } from "./CodeBlock";
import { import {
Box, Box,
ChevronDown,
CircleArrowUp, CircleArrowUp,
CircleCheck, CircleCheck,
Container,
HelpCircle, HelpCircle,
Timer, Timer,
TriangleAlert, TriangleAlert,
X, X,
} from "lucide-react"; } from "lucide-react";
import Badge from "./Badge"; import Badge from "./Badge";
import { getDescription } from "../utils"; import { cn, getDescription, truncateArray } from "../utils";
const clickable_registries = [ const clickable_registries = [
"registry-1.docker.io", "registry-1.docker.io",
@@ -30,12 +35,16 @@ const clickable_registries = [
export default function Image({ data }: { data: Image }) { export default function Image({ data }: { data: Image }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showUsedBy, setShowUsedBy] = useState(false);
const handleOpen = () => { const handleOpen = () => {
setOpen(true); setOpen(true);
}; };
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false);
}; };
const toggleShowUsedBy = () => {
setShowUsedBy(!showUsedBy)
}
const new_reference = const new_reference =
data.result.info?.type == "version" data.result.info?.type == "version"
? data.reference.split(":")[0] + ":" + data.result.info.new_tag ? data.reference.split(":")[0] + ":" + data.result.info.new_tag
@@ -140,6 +149,21 @@ export default function Image({ data }: { data: Image }) {
Checked in <b>{data.time}</b> ms Checked in <b>{data.time}</b> ms
</span> </span>
</div> </div>
{data.used_by.length !== 0 && (
<div className="flex items-start gap-3">
<Container className="size-6 shrink-0 text-gray-500" />
<div className="flex items-start gap-1">
<span className="shrink-0">Used by</span>
{data.used_by.length > 1 ? (
<button onClick={toggleShowUsedBy} className={cn("flex gap-x-2 flex-wrap font-bold", !showUsedBy && "underline")}>
{showUsedBy ? data.used_by.map((container) => <><pre>{container}</pre></>) : truncateArray(data.used_by)}
</button>
) : (
data.used_by[0]
)}
</div>
</div>
)}
{data.result.error && ( {data.result.error && (
<div className="break-before mt-4 flex items-center gap-3 overflow-hidden rounded-md bg-yellow-400/10 px-3 py-2"> <div className="break-before mt-4 flex items-center gap-3 overflow-hidden rounded-md bg-yellow-400/10 px-3 py-2">
<TriangleAlert className="size-6 shrink-0 text-yellow-500" /> <TriangleAlert className="size-6 shrink-0 text-yellow-500" />

View File

@@ -6,7 +6,7 @@ import {
} from "@headlessui/react"; } from "@headlessui/react";
import { ChevronDown, Check } from "lucide-react"; import { ChevronDown, Check } from "lucide-react";
import { theme } from "../../theme"; import { theme } from "../../theme";
import { cn } from "../../utils"; import { cn, truncateArray } from "../../utils";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
export default function Select({ export default function Select({
@@ -27,7 +27,7 @@ export default function Select({
<div className="relative"> <div className="relative">
<ListboxButton <ListboxButton
className={cn( className={cn(
`flex overflow-x-hidden w-full gap-2 rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 group relative items-center py-1.5 pl-3 pr-2 text-left transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 sm:text-sm/6`, `flex w-full gap-2 overflow-x-hidden rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 group relative items-center py-1.5 pl-3 pr-2 text-left transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 sm:text-sm/6`,
selectedItems.length == 0 selectedItems.length == 0
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white` ? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
: "text-black dark:text-white", : "text-black dark:text-white",
@@ -46,13 +46,12 @@ export default function Select({
<span className="truncate"> <span className="truncate">
{selectedItems.length == 0 {selectedItems.length == 0
? placeholder ? placeholder
: selectedItems.length == 1 : truncateArray(selectedItems)}
? selectedItems[0] </span>
: `${selectedItems[0]} +${(selectedItems.length - 1).toString()} more`}</span>
<ChevronDown <ChevronDown
aria-hidden="true" aria-hidden="true"
className={`size-5 shrink-0 ml-auto self-center text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 group-hover:text-black sm:size-4 group-hover:dark:text-white`} className={`ml-auto size-5 shrink-0 self-center text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 group-hover:text-black sm:size-4 group-hover:dark:text-white`}
/> />
<div <div
className="absolute -bottom-px left-1/2 h-full w-0 -translate-x-1/2 rounded-md border-b-2 border-b-blue-600 transition-all duration-200 group-data-[open]:w-[calc(100%+2px)]" className="absolute -bottom-px left-1/2 h-full w-0 -translate-x-1/2 rounded-md border-b-2 border-b-blue-600 transition-all duration-200 group-data-[open]:w-[calc(100%+2px)]"

View File

@@ -28,7 +28,7 @@ export interface Image {
}; };
time: number; time: number;
server: string | null; server: string | null;
in_use: boolean | null; used_by: string[];
} }
interface VersionInfo { interface VersionInfo {

View File

@@ -28,3 +28,11 @@ export function getDescription(image: Image) {
return "Unknown"; return "Unknown";
} }
} }
export function truncateArray(arr: string[]) {
if (arr.length > 1) {
return `${arr[0]} +${(arr.length - 1).toString()} more`
} else if (arr.length == 1) {
return arr[0]
}
}