m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-08 05:03:49 -05:00

23 Commits

Author SHA1 Message Date
Sergio
90af772dd7 chore: bump project version 2025-10-14 18:06:50 +03:00
makonde-on-git
6ab06db5cb feat: allow usage without a daemon (#142) 2025-10-14 18:05:11 +03:00
Sergio
547d418401 docs: fix incorrect compose example with environment variables 2025-09-28 21:49:35 +03:00
Sergio
54f00c6c61 add a full stop 2025-09-10 11:57:42 +03:00
Sergio
b55ddfd3ad reword README 2025-09-10 11:57:17 +03:00
Sergio
14887cb766 add notice 2025-09-10 11:24:24 +03:00
Sergio
b0d0a02182 docs: update example homepage widget to conform to latest config spec
Co-authored-by: Valdr687 <106614142+Valdr687@users.noreply.github.com>
2025-08-29 17:42:56 +03:00
Sergio
c351a38642 docs: update homepage widget example
Remove usage of non-default port 9000 and change server ip placeholder to hostname instead
2025-08-25 19:11:59 +03:00
Sergio
ebb7c18bca fix: include OCI image manifest MIME type in Accept header when
checking for a digest update

Closes #132
2025-08-11 13:01:53 +03:00
Sergio
b542f1bac5 chore: bump project version 2025-05-27 14:51:48 +03:00
Sergio
34ae9cb36f fix: ignore empty refresh interval 2025-05-27 14:51:48 +03:00
Sergio
e015afbaca chore: update project version in Cargo.lock
This should have happened automatically when Cargo.toml was updated
2025-05-27 14:51:13 +03:00
Sergio
6dc1030a3b chore: bump project version 2025-05-21 19:30:30 +03:00
Sergio
d2c1651761 docs: update automatic refresh docs to mention TZ configuration 2025-05-21 11:39:41 +03:00
Sergio
8b3cf73f65 docs: add docs for environment variables 2025-05-21 11:34:16 +03:00
Sergio
6d88036914 feat: add timezone support 2025-05-21 11:13:27 +03:00
Sergio
a06266264d refactor: avoid a clone if extra images are empty 2025-05-20 17:46:09 +03:00
Sergio
c260874459 feat: add support for configuring through environment variables 2025-05-20 17:03:36 +03:00
Sergio
3e42ac338a fix: errors in http.rs 2025-05-10 20:55:14 +03:00
Sergio
15784eb4f1 fix: errors in http.rs 2025-05-10 20:52:04 +03:00
Sergio
2623f52a20 fix: handle 502 gracefully
Fixes #104
2025-05-10 20:48:22 +03:00
Sergio
8a5b0555f7 feat: add new filters 2025-05-09 13:19:42 +03:00
Sergio
2e1b0945e0 chore: bump project version 2025-05-09 09:51:16 +03:00
22 changed files with 506 additions and 101 deletions

87
Cargo.lock generated
View File

@@ -260,6 +260,27 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402"
dependencies = [
"parse-zoneinfo",
"phf_codegen",
]
[[package]]
name = "clap"
version = "4.5.31"
@@ -355,11 +376,13 @@ dependencies = [
[[package]]
name = "cup"
version = "3.2.3"
version = "3.4.2"
dependencies = [
"bollard",
"chrono",
"chrono-tz",
"clap",
"envy",
"futures",
"http-auth",
"http-link",
@@ -423,6 +446,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1192,6 +1224,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -1243,6 +1284,44 @@ dependencies = [
"sha2",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1688,6 +1767,12 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"

View File

@@ -1,6 +1,6 @@
[package]
name = "cup"
version = "3.2.3"
version = "3.4.3"
edition = "2021"
[dependencies]
@@ -25,6 +25,8 @@ itertools = "0.14.0"
serde_json = "1.0.133"
serde = "1.0.215"
tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true }
envy = "0.4.2"
chrono-tz = "0.10.3"
[features]
default = ["server", "cli"]

17
NOTICE.md Normal file
View File

@@ -0,0 +1,17 @@
Hello,
I have an important announcement to make.
### The situation
You may have noticed that the last few months Cup's development has stalled. I had very little time to work on it. Tomorrow, the 11th of September, 2025, I am starting my last year of school. It is very important to me to get into a good university, so I will be studying all day, with no time to work on my projects.
What this means for you is that the development of Cup is paused, at least until June 2026. That means no new features and no bugfixes. If Cup works fine for you and you're comfortable with not getting any updates (this does _not_ mean Cup is suddenly insecure by the way), you can keep using it. Otherwise, there are many alternatives you can look at, which are actively maintained and may provide the features you need.
### How you can help
If you're a Rust developer, I would really appreciate it if you could start contributing to Cup. There are a bunch of open issues that need to be worked on. I can find some time to review PRs. You can also fork the repository and do your own thing, if you prefer.
I've also left a few new features in the `v4` branch and a rewrite I started in `rewrite`, because I feel like the code is unmaintainable in its current state. I'd love it if someone could help work on that.
That's all I had to say. I'm sorry if I let you down, this was a really hard decision to make. I would like to thank all of you for your help and support, it really means a lot to me. I hope I can continue working on the project once I'm done with my final exams!

View File

@@ -7,6 +7,8 @@
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/sergi0g/cup)
[![Discord](https://img.shields.io/discord/1337705080518086658)](https://discord.gg/jmh5ctzwNG)
> [!IMPORTANT]
> There have been some important changes regarding Cup's development. Read more [here](./NOTICE.md).
Cup is the easiest way to check for container image updates.

View File

@@ -70,7 +70,7 @@
},
"socket": {
"type": "string",
"description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman.",
"description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman. To disable use \"none\" as value.",
"minLength": 1
},
"servers": {

View File

@@ -51,29 +51,24 @@ Credit: [@agrmohit](https://github.com/agrmohit)
```yaml
widget:
type: customapi
url: http://<SERVER_IP>:9000/api/v3/json
url: http://<SERVER_HOSTNAME>/api/v3/json
refreshInterval: 10000
method: GET
mappings:
- field:
metrics: monitored_images
- field: metrics.monitored_images
label: Monitored images
format: number
- field:
metrics: up_to_date
- field: metrics.up_to_date
label: Up to date
format: number
- field:
metrics: updates_available
- field: metrics.updates_available
label: Available updates
format: number
- field:
metrics: unknown
- field: metrics.unknown
label: Unknown
format: number
```
Preview:
<Image src={widget2} />
Credit: [@remussamoila](https://github.com/remussamoila)
Credit: [@remussamoila](https://github.com/remussamoila) and [@Valdr687](https://github.com/Valdr687)

View File

@@ -1,3 +1,5 @@
import { Callout } from "nextra/components";
# Automatic refresh
Cup can automatically refresh the results when running in server mode. Simply add this to your config:
@@ -9,4 +11,8 @@ Cup can automatically refresh the results when running in server mode. Simply ad
}
```
You can use a cron expression to specify the refresh interval. Note that seconds are not optional. The reference is [here](https://github.com/Hexagon/croner-rust#pattern)
You can use a cron expression to specify the refresh interval. Note that seconds are not optional. The reference is [here](https://github.com/Hexagon/croner-rust#pattern).
<Callout>
If you use a schedule with absolute time (e.g. every day at 6 AM), make sure to set the `TZ` environment variable to your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List).
</Callout>

View File

@@ -23,7 +23,7 @@ For example, if using Podman, you might do
$ cup -s /run/user/1000/podman/podman.sock check
```
This option is also available in the configuration file and it's best to put it there.
This option is also available in the configuration file and it's best to put it there. If both are defined the CLI `-s` option takes precedence.
<Cards.Card
icon={<IconPlug />}
@@ -31,6 +31,8 @@ This option is also available in the configuration file and it's best to put it
href="/docs/configuration/socket"
/>
To disable Docker/Podman socket use "none" as value.
## Configuration file
Cup has an option to be configured from a configuration file named `cup.json`.
@@ -109,3 +111,36 @@ $ docker run -tv /var/run/docker.sock:/var/run/docker.sock -v /home/sergio/.conf
```
</Steps>
## Environment Variables
Want to make a quick change without editing your `config.json`? Cup also supports some configuration options from environment variables.
Here are the ones currently available:
- `CUP_AGENT` - Agent mode
- `CUP_IGNORE_UPDATE_TYPE` - Ignoring specific update types
- `CUP_REFRESH_INTERVAL` - Automatic refresh
- `CUP_SOCKET` - Socket
- `CUP_THEME` - Theme
Refer to the configuration page for more information on each of these.
Here's an example of a Docker Compose file using them:
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup:latest
command: serve
ports:
- 8000:8000
environment:
CUP_AGENT: "true"
CUP_IGNORE_UPDATE_TYPE: major
CUP_REFRESH_INTERVAL: "0 */30 * * * *"
CUP_SOCKET: tcp://localhost:2375
CUP_THEME: blue
```
<Callout>
Heads up!
Any configuration option you set with environment variables **always** overrides anything in your `cup.json`.
</Callout>

View File

@@ -17,3 +17,12 @@ You can also specify a TCP socket if you're using a remote Docker host or a [pro
// Other options
}
```
Or use the "none" value to disable any Docker/Podman query:
```jsonc
{
"socket": "none"
// Other options
}
```

View File

@@ -90,7 +90,9 @@ pub async fn get_updates(
// Merge references argument with references from config
let all_references = match &references {
Some(refs) => {
refs.clone().extend_from_slice(&ctx.config.images.extra);
if !ctx.config.images.extra.is_empty() {
refs.clone().extend_from_slice(&ctx.config.images.extra);
}
refs
}
None => &ctx.config.images.extra,
@@ -100,7 +102,8 @@ pub async fn get_updates(
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()));
ctx.logger
.debug(format!("Found {} images in use", in_use_images.len()));
// Complete in_use field
images.iter_mut().for_each(|image| {
@@ -204,10 +207,7 @@ pub async fn get_updates(
}
// Await all the futures
let images = join_all(handles).await;
let mut updates: Vec<Update> = images
.iter()
.map(|image| image.to_update())
.collect();
let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect();
updates.extend_from_slice(&remote_updates);
updates
}

View File

@@ -1,10 +1,19 @@
use std::path::PathBuf;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde::Deserializer;
use std::env;
use std::mem;
use std::path::PathBuf;
use crate::error;
// We can't assign `a` to `b` in the loop in `Config::load`, so we'll have to use swap. It looks ugly so now we have a macro for it.
macro_rules! swap {
($a:expr, $b:expr) => {
mem::swap(&mut $a, &mut $b)
};
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
@@ -56,6 +65,7 @@ pub struct Config {
pub agent: bool,
pub ignore_update_type: UpdateType,
pub images: ImageConfig,
#[serde(deserialize_with = "empty_as_none")]
pub refresh_interval: Option<String>,
pub registries: FxHashMap<String, RegistryConfig>,
pub servers: FxHashMap<String, String>,
@@ -78,8 +88,41 @@ impl Config {
}
}
/// Loads file and env config and merges them
pub fn load(&mut self, path: Option<PathBuf>) -> Self {
let mut config = self.load_file(path);
// Get environment variables with CUP_ prefix
let env_vars: FxHashMap<String, String> =
env::vars().filter(|(k, _)| k.starts_with("CUP_")).collect();
if !env_vars.is_empty() {
if let Ok(mut cfg) = envy::prefixed("CUP_").from_env::<Config>() {
// If we have environment variables, override config.json options
for (key, _) in env_vars {
match key.as_str() {
"CUP_AGENT" => config.agent = cfg.agent,
#[rustfmt::skip]
"CUP_IGNORE_UPDATE_TYPE" => swap!(config.ignore_update_type, cfg.ignore_update_type),
#[rustfmt::skip]
"CUP_REFRESH_INTERVAL" => swap!(config.refresh_interval, cfg.refresh_interval),
"CUP_SOCKET" => swap!(config.socket, cfg.socket),
"CUP_THEME" => swap!(config.theme, cfg.theme),
// The syntax for these is slightly more complicated, not sure if they should be enabled or not. Let's stick to simple types for now.
// "CUP_IMAGES" => swap!(config.images, cfg.images),
// "CUP_REGISTRIES" => swap!(config.registries, cfg.registries),
// "CUP_SERVERS" => swap!(config.servers, cfg.servers),
_ => (), // Maybe print a warning if other CUP_ variables are detected
}
}
}
}
config
}
/// Reads the config from the file path provided and returns the parsed result.
pub fn load(&self, path: Option<PathBuf>) -> Self {
fn load_file(&self, path: Option<PathBuf>) -> Self {
let raw_config = match &path {
Some(path) => std::fs::read_to_string(path),
None => return Self::new(), // Empty config
@@ -93,7 +136,7 @@ impl Config {
self.parse(&raw_config.unwrap()) // We can safely unwrap here
}
/// Parses and validates the config.
pub fn parse(&self, raw_config: &str) -> Self {
fn parse(&self, raw_config: &str) -> Self {
let config: Self = match serde_json::from_str(raw_config) {
Ok(config) => config,
Err(e) => error!("Unexpected error occured while parsing config: {}", e),
@@ -110,3 +153,15 @@ impl Default for Config {
Self::new()
}
}
fn empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
Ok(Some(s))
}
}

View File

@@ -41,6 +41,9 @@ pub async fn get_images_from_docker_daemon(
ctx: &Context,
references: &Option<Vec<String>>,
) -> Vec<Image> {
if ctx.config.socket.as_deref() == Some("none") {
return vec![];
}
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let mut swarm_images = match client.list_services::<String>(None).await {
Ok(services) => services
@@ -96,6 +99,10 @@ pub async fn get_images_from_docker_daemon(
}
pub async fn get_in_use_images(ctx: &Context) -> Vec<String> {
if ctx.config.socket.as_deref() == Some("none") {
return vec![];
}
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let containers = match client

View File

@@ -69,6 +69,10 @@ impl Client {
self.ctx.logger.warn(&message);
Err(message)
}
} else if status == 502 {
let message = format!("{} {}: The registry is currently unavailabile (returned status code 502).", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if status.as_u16() <= 400 {
Ok(response)
} else {

View File

@@ -58,7 +58,7 @@ pub async fn get_latest_digest(
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
);
let authorization = to_bearer_string(&token);
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())];
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json")), ("Authorization", authorization.as_deref())];
let response = client.head(&url, &headers).await;
let time = start.elapsed().unwrap().as_millis() as u32;

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use std::{env, sync::Arc};
use chrono::Local;
use chrono_tz::Tz;
use liquid::{object, Object, ValueView};
use rustc_hash::FxHashMap;
use serde_json::Value;
@@ -54,15 +55,22 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
let scheduler = JobScheduler::new().await.unwrap();
let data = Arc::new(Mutex::new(data));
let data_copy = data.clone();
let tz = env::var("TZ")
.map(|tz| tz.parse().unwrap_or(Tz::UTC))
.unwrap_or(Tz::UTC);
if let Some(interval) = &ctx.config.refresh_interval {
scheduler
.add(
match Job::new_async(interval, move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.lock().await.refresh().await;
})
}) {
match Job::new_async_tz(
interval,
tz,
move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.lock().await.refresh().await;
})
},
) {
Ok(job) => job,
Err(e) => match e {
tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!(

View File

@@ -14,6 +14,7 @@ import DataLoadingError from "./components/DataLoadingError";
import Filters from "./components/Filters";
import { Filter, FilterX } from "lucide-react";
import { WithTooltip } from "./components/ui/Tooltip";
import { getDescription } from "./utils";
const SORT_ORDER = [
"monitored_images",
@@ -32,6 +33,8 @@ function App() {
const [showFilters, setShowFilters] = useState<boolean>(false);
const [filters, setFilters] = useState<FiltersType>({
onlyInUse: false,
registries: [],
statuses: [],
});
const [searchQuery, setSearchQuery] = useState("");
@@ -39,7 +42,7 @@ function App() {
if (isError || !data) return <DataLoadingError />;
const toggleShowFilters = () => {
if (showFilters) {
setFilters({ onlyInUse: false });
setFilters({ onlyInUse: false, registries: [], statuses: [] });
}
setShowFilters(!showFilters);
};
@@ -95,7 +98,13 @@ function App() {
<Search onChange={setSearchQuery} />
</div>
{showFilters && (
<Filters filters={filters} setFilters={setFilters} />
<Filters
filters={filters}
setFilters={setFilters}
registries={[
...new Set(data.images.map((image) => image.parts.registry)),
]}
/>
)}
<ul>
{Object.entries(
@@ -116,6 +125,16 @@ function App() {
.filter((image) =>
filters.onlyInUse ? !!image.in_use : true,
)
.filter((image) =>
filters.registries.length == 0
? true
: filters.registries.includes(image.parts.registry),
)
.filter((image) =>
filters.statuses.length == 0
? true
: filters.statuses.includes(getDescription(image)),
)
.filter((image) => image.reference.includes(searchQuery))
.map((image) => (
<Image data={image} key={image.reference} />

View File

@@ -1,33 +1,81 @@
import { useState } from "react";
import { theme } from "../theme";
import { Filters as FiltersType } from "../types";
import { Checkbox } from "./ui/Checkbox";
import Select from "./ui/Select";
import { Server } from "lucide-react";
interface Props {
filters: FiltersType;
setFilters: (filters: FiltersType) => void;
registries: string[];
}
export default function Filters({ filters, setFilters }: Props) {
const STATUSES = [
"Major update",
"Minor update",
"Patch update",
"Digest update",
"Up to date",
"Unknown",
];
export default function Filters({ filters, setFilters, registries }: Props) {
const [selectedRegistries, setSelectedRegistries] = useState<
FiltersType["registries"]
>([]);
const [selectedStatuses, setSelectedStatuses] = useState<
FiltersType["statuses"]
>([]);
const handleSelectRegistries = (registries: string[]) => {
setSelectedRegistries(registries);
setFilters({
...filters,
registries,
});
};
const handleSelectStatuses = (statuses: string[]) => {
if (statuses.every((status) => STATUSES.includes(status))) {
setSelectedStatuses(statuses as FiltersType["statuses"]);
setFilters({
...filters,
statuses: statuses as FiltersType["statuses"],
});
}
};
return (
<div className="flex w-fit flex-col gap-2 px-6 py-4">
<div className="ml-auto flex items-center space-x-2">
<Checkbox
id="inUse"
checked={filters.onlyInUse}
onCheckedChange={(value) => {
setFilters({
...filters,
onlyInUse: value === "indeterminate" ? false : value,
});
}}
/>
<label
htmlFor="inUse"
className={`text-sm font-medium leading-none text-${theme}-600 dark:text-${theme}-400 hover:text-black dark:hover:text-white peer-hover:text-black peer-hover:dark:text-white peer-data-[state=checked]:text-black dark:peer-data-[state=checked]:text-white transition-colors duration-200`}
>
Hide unused images
</label>
</div>
<div className="flex w-full flex-col gap-4 px-6 py-4 sm:flex-row">
<div className="flex items-center space-x-2">
<Checkbox
id="inUse"
checked={filters.onlyInUse}
onCheckedChange={(value) => {
setFilters({
...filters,
onlyInUse: value === "indeterminate" ? false : value,
});
}}
/>
<label
htmlFor="inUse"
className={`text-sm font-medium leading-none text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 hover:text-black peer-hover:text-black peer-data-[state=checked]:text-black dark:hover:text-white peer-hover:dark:text-white dark:peer-data-[state=checked]:text-white`}
>
Hide unused images
</label>
</div>
<Select
Icon={Server}
items={registries}
placeholder="Registry"
selectedItems={selectedRegistries}
setSelectedItems={handleSelectRegistries}
/>
<Select
items={STATUSES}
placeholder="Update type"
selectedItems={selectedStatuses}
setSelectedItems={handleSelectStatuses}
/>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
X,
} from "lucide-react";
import Badge from "./Badge";
import { getDescription } from "../utils";
const clickable_registries = [
"registry-1.docker.io",
@@ -39,7 +40,7 @@ export default function Image({ data }: { data: Image }) {
data.result.info?.type == "version"
? data.reference.split(":")[0] + ":" + data.result.info.new_tag
: data.reference;
const info = getInfo(data)!;
const info = getInfo(data);
let url: string | null = null;
if (data.url) {
url = data.url;
@@ -182,54 +183,49 @@ export default function Image({ data }: { data: Image }) {
);
}
function getInfo(data: Image):
| {
color: string;
icon: typeof HelpCircle;
description: string;
}
| undefined {
switch (data.result.has_update) {
case null:
function getInfo(data: Image): {
color: string;
icon: typeof HelpCircle;
description: string;
} {
const description = getDescription(data);
switch (description) {
case "Unknown":
return {
color: "text-gray-500",
icon: HelpCircle,
description: "Unknown",
description,
};
case false:
case "Up to date":
return {
color: "text-green-500",
icon: CircleCheck,
description: "Up to date",
description,
};
case "Major update":
return {
color: "text-red-500",
icon: CircleArrowUp,
description,
};
case "Minor update":
return {
color: "text-yellow-500",
icon: CircleArrowUp,
description,
};
case "Patch update":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description,
};
case "Digest update":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description,
};
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return {
color: "text-red-500",
icon: CircleArrowUp,
description: "Major update",
};
case "minor":
return {
color: "text-yellow-500",
icon: CircleArrowUp,
description: "Minor update",
};
case "patch":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description: "Patch update",
};
}
} else if (data.result.info?.type === "digest") {
return {
color: "text-blue-500",
icon: CircleArrowUp,
description: "Update available",
};
}
}
}

View File

@@ -0,0 +1,84 @@
import {
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from "@headlessui/react";
import { ChevronDown, Check } from "lucide-react";
import { theme } from "../../theme";
import { cn } from "../../utils";
import { Server } from "lucide-react";
export default function Select({
items,
Icon,
placeholder,
selectedItems,
setSelectedItems,
}: {
items: string[];
Icon?: typeof Server;
placeholder: string;
selectedItems: string[];
setSelectedItems: (items: string[]) => void;
}) {
return (
<Listbox value={selectedItems} onChange={setSelectedItems} multiple>
<div className="relative">
<ListboxButton
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`,
selectedItems.length == 0
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
: "text-black dark:text-white",
)}
>
{Icon && (
<Icon
className={cn(
"size-4 shrink-0",
selectedItems.length == 0
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
: "text-black dark:text-white",
)}
/>
)}
<span className="truncate">
{selectedItems.length == 0
? placeholder
: selectedItems.length == 1
? selectedItems[0]
: `${selectedItems[0]} +${(selectedItems.length - 1).toString()} more`}</span>
<ChevronDown
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`}
/>
<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)]"
style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
></div>
</ListboxButton>
<ListboxOptions
transition
className={`absolute z-10 mt-1 max-h-56 w-max overflow-y-auto overflow-x-hidden rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 text-base shadow-lg ring-1 ring-black/5 focus:outline-none data-[closed]:data-[leave]:opacity-0 data-[leave]:transition data-[leave]:duration-100 data-[leave]:ease-in sm:text-sm`}
>
{items.map((item) => (
<ListboxOption
key={item}
value={item}
className={`group relative cursor-pointer text-nowrap py-2 pl-3 pr-9 data-[focus]:outline-none text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 data-[focus]:bg-black/10 data-[focus]:text-black dark:data-[focus]:bg-white/10 data-[focus]:dark:text-white`}
>
{item}
<span
className={`absolute inset-y-0 right-2 flex items-center text-${theme}-600 dark:text-${theme}-400 group-[:not([data-selected])]:hidden group-data-[focus]:text-black group-data-[focus]:dark:text-white`}
>
<Check aria-hidden="true" className="size-4" />
</span>
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
);
}

View File

@@ -47,4 +47,13 @@ interface DigestInfo {
export interface Filters {
onlyInUse: boolean;
registries: string[];
statuses: (
| "Major update"
| "Minor update"
| "Patch update"
| "Digest update"
| "Up to date"
| "Unknown"
)[];
}

View File

@@ -1,6 +1,30 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type { Image } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getDescription(image: Image) {
switch (image.result.has_update) {
case null:
return "Unknown";
case false:
return "Up to date";
case true:
if (image.result.info?.type === "version") {
switch (image.result.info.version_update_type) {
case "major":
return "Major update";
case "minor":
return "Minor update";
case "patch":
return "Patch update";
}
} else if (image.result.info?.type === "digest") {
return "Digest update";
}
return "Unknown";
}
}

View File

@@ -32,14 +32,14 @@ export default {
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["*", "dark", "hover", "placeholder"],
variants: ["*", "dark", "hover", "placeholder", "data-[placeholder]"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["*:dark", "dark", "dark:hover", "placeholder:dark"],
variants: ["*:dark", "dark", "dark:hover", "placeholder:dark", "data-[placeholder]:dark"],
},
{
pattern: /text-(gray|neutral)-700/,
pattern: /text-(gray|neutral)-(500|700)/,
},
{
pattern: /text-(gray|neutral)-950/,