diff --git a/src/check.rs b/src/check.rs index 2e6a5e7..e334ab0 100644 --- a/src/check.rs +++ b/src/check.rs @@ -3,7 +3,7 @@ use itertools::Itertools; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ - docker::get_images_from_docker_daemon, + docker::{get_images_from_docker_daemon, get_in_use_images}, http::Client, registry::{check_auth, get_token}, structs::{image::Image, update::Update}, @@ -99,6 +99,15 @@ pub async fn get_updates( // Get local images ctx.logger.debug("Retrieving images to be checked"); let mut images = get_images_from_docker_daemon(ctx, references).await; + let in_use_images = get_in_use_images(ctx).await; + ctx.logger.debug(format!("Found {} images in use", in_use_images.len())); + + // Complete in_use field + images.iter_mut().for_each(|image| { + if in_use_images.contains(&image.reference) { + image.in_use = true + } + }); // Add extra images from references if !all_references.is_empty() { @@ -195,7 +204,10 @@ pub async fn get_updates( } // Await all the futures let images = join_all(handles).await; - let mut updates: Vec = images.iter().map(|image| image.to_update()).collect(); + let mut updates: Vec = images + .iter() + .map(|image| image.to_update()) + .collect(); updates.extend_from_slice(&remote_updates); updates } diff --git a/src/docker.rs b/src/docker.rs index fbf3159..9d31d3e 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -1,4 +1,4 @@ -use bollard::{models::ImageInspect, ClientVersion, Docker}; +use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker}; use futures::future::join_all; @@ -94,3 +94,34 @@ pub async fn get_images_from_docker_daemon( local_images.append(&mut swarm_images); local_images } + +pub async fn get_in_use_images(ctx: &Context) -> Vec { + let client: Docker = create_docker_client(ctx.config.socket.as_deref()); + + let containers = match client + .list_containers::(Some(ListContainersOptions { + all: true, + ..Default::default() + })) + .await + { + Ok(containers) => containers, + Err(e) => { + error!("Failed to retrieve list of containers available!\n{}", e) + } + }; + + containers + .iter() + .filter_map(|container| match &container.image { + Some(image) => Some({ + if image.contains(":") { + image.clone() + } else { + format!("{image}:latest") + } + }), + None => None, + }) + .collect() +} diff --git a/src/structs/image.rs b/src/structs/image.rs index f8b0620..17a19b7 100644 --- a/src/structs/image.rs +++ b/src/structs/image.rs @@ -38,6 +38,7 @@ pub struct Image { pub url: Option, pub digest_info: Option, pub version_info: Option, + pub in_use: bool, pub error: Option, pub time_ms: u32, } @@ -221,6 +222,7 @@ impl Image { }, time: self.time_ms, server: None, + in_use: self.in_use, status: has_update, } } diff --git a/src/structs/update.rs b/src/structs/update.rs index 0c2f075..89f3c48 100644 --- a/src/structs/update.rs +++ b/src/structs/update.rs @@ -11,6 +11,7 @@ pub struct Update { pub result: UpdateResult, pub time: u32, pub server: Option, + pub in_use: bool, #[serde(skip_serializing, skip_deserializing)] pub status: Status, } diff --git a/web/bun.lock b/web/bun.lock index 9033547..35d9377 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,6 +5,7 @@ "name": "web", "dependencies": { "@headlessui/react": "^2.1.10", + "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-tooltip": "^1.1.2", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -145,6 +146,8 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pHVzDYsnaDmBlAuwim45y3soIN8H4R7KbkSVirGhXO+R/kO2OLCe0eucUEbddaTcdMHHdzcIGHtZSMSQlA+apw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -175,6 +178,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], diff --git a/web/package.json b/web/package.json index 2a4f26b..cfa1a38 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@headlessui/react": "^2.1.10", + "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-tooltip": "^1.1.2", "clsx": "^2.1.1", "date-fns": "^3.6.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 07c15b2..cdd1e80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,12 +4,16 @@ import Statistic from "./components/Statistic"; import Image from "./components/Image"; import { LastChecked } from "./components/LastChecked"; import Loading from "./components/Loading"; +import { Filters as FiltersType } from "./types"; import { theme } from "./theme"; import RefreshButton from "./components/RefreshButton"; import Search from "./components/Search"; import { Server } from "./components/Server"; import { useData } from "./hooks/use-data"; import DataLoadingError from "./components/DataLoadingError"; +import Filters from "./components/Filters"; +import { Filter, FilterX } from "lucide-react"; +import { WithTooltip } from "./components/ui/Tooltip"; const SORT_ORDER = [ "monitored_images", @@ -24,10 +28,22 @@ const SORT_ORDER = [ function App() { const { data, isLoading, isError } = useData(); + + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + onlyInUse: false, + }); const [searchQuery, setSearchQuery] = useState(""); if (isLoading) return ; if (isError || !data) return ; + const toggleShowFilters = () => { + if (showFilters) { + setFilters({ onlyInUse: false }); + } + setShowFilters(!showFilters); + }; + return (
- +
+ + + + +
+ {showFilters && ( + + )}
    {Object.entries( data.images.reduce>( @@ -85,6 +113,9 @@ function App() { .map(([server, images]) => ( {images + .filter((image) => + filters.onlyInUse ? !!image.in_use : true, + ) .filter((image) => image.reference.includes(searchQuery)) .map((image) => ( diff --git a/web/src/components/Filters.tsx b/web/src/components/Filters.tsx new file mode 100644 index 0000000..f1f4da6 --- /dev/null +++ b/web/src/components/Filters.tsx @@ -0,0 +1,33 @@ +import { theme } from "../theme"; +import { Filters as FiltersType } from "../types"; +import { Checkbox } from "./ui/Checkbox"; + +interface Props { + filters: FiltersType; + setFilters: (filters: FiltersType) => void; +} + +export default function Filters({ filters, setFilters }: Props) { + return ( +
    +
    + { + setFilters({ + ...filters, + onlyInUse: value === "indeterminate" ? false : value, + }); + }} + /> + +
    +
    + ); +} diff --git a/web/src/components/Image.tsx b/web/src/components/Image.tsx index 2fceb47..df0b2b9 100644 --- a/web/src/components/Image.tsx +++ b/web/src/components/Image.tsx @@ -5,7 +5,7 @@ import { DialogPanel, DialogTitle, } from "@headlessui/react"; -import { WithTooltip } from "./Tooltip"; +import { WithTooltip } from "./ui/Tooltip"; import type { Image } from "../types"; import { theme } from "../theme"; import { CodeBlock } from "./CodeBlock"; diff --git a/web/src/components/RefreshButton.tsx b/web/src/components/RefreshButton.tsx index ee9e7d0..17ead66 100644 --- a/web/src/components/RefreshButton.tsx +++ b/web/src/components/RefreshButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { WithTooltip } from "./Tooltip"; +import { WithTooltip } from "./ui/Tooltip"; export default function RefreshButton() { const [disabled, setDisabled] = useState(false); diff --git a/web/src/components/ui/Checkbox.tsx b/web/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..af7457f --- /dev/null +++ b/web/src/components/ui/Checkbox.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { cn } from "../../utils"; +import { theme } from "../../theme"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/web/src/components/Tooltip.tsx b/web/src/components/ui/Tooltip.tsx similarity index 95% rename from web/src/components/Tooltip.tsx rename to web/src/components/ui/Tooltip.tsx index 3c22cb0..4a4d4ec 100644 --- a/web/src/components/Tooltip.tsx +++ b/web/src/components/ui/Tooltip.tsx @@ -1,7 +1,7 @@ import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip"; -import { cn } from "../utils"; +import { cn } from "../../utils"; import { forwardRef, ReactNode } from "react"; -import { theme } from "../theme"; +import { theme } from "../../theme"; const TooltipContent = forwardRef< React.ElementRef, diff --git a/web/src/types.ts b/web/src/types.ts index 9b17118..c1086dc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -28,6 +28,7 @@ export interface Image { }; time: number; server: string | null; + in_use: boolean | null; } interface VersionInfo { @@ -43,3 +44,7 @@ interface DigestInfo { local_digests: string[]; remote_digest: string; } + +export interface Filters { + onlyInUse: boolean; +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index dc3afbe..557bc79 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./src/App.tsx", "./src/components/*.tsx", "./index.liquid"], + content: ["./src/App.tsx", "./src/components/**/*.tsx", "./index.liquid"], theme: { extend: {}, }, @@ -41,6 +41,10 @@ export default { { pattern: /text-(gray|neutral)-700/, }, + { + pattern: /text-(gray|neutral)-950/, + variants: ["dark:group-data-[state=checked]"] + }, { pattern: /text-(gray|neutral)-800/, variants: ["group-data-[hover]"], @@ -54,10 +58,10 @@ export default { variants: ["dark"], }, { - pattern: /border-(gray|neutral)-(200|300)/, + pattern: /border-(gray|neutral)-(600|300|400)/, }, { - pattern: /border-(gray|neutral)-(700|800|900)/, + pattern: /border-(gray|neutral)-(400|700|800|900)/, variants: ["dark"], }, {