mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-08 05:03:49 -05:00
Compare commits
24 Commits
c8229d7370
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c745a249f5 | ||
|
|
90af772dd7 | ||
|
|
6ab06db5cb | ||
|
|
547d418401 | ||
|
|
54f00c6c61 | ||
|
|
b55ddfd3ad | ||
|
|
14887cb766 | ||
|
|
b0d0a02182 | ||
|
|
c351a38642 | ||
|
|
ebb7c18bca | ||
|
|
b542f1bac5 | ||
|
|
34ae9cb36f | ||
|
|
e015afbaca | ||
|
|
6dc1030a3b | ||
|
|
d2c1651761 | ||
|
|
8b3cf73f65 | ||
|
|
6d88036914 | ||
|
|
a06266264d | ||
|
|
c260874459 | ||
|
|
3e42ac338a | ||
|
|
15784eb4f1 | ||
|
|
2623f52a20 | ||
|
|
8a5b0555f7 | ||
|
|
2e1b0945e0 |
87
Cargo.lock
generated
87
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
17
NOTICE.md
Normal 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!
|
||||
@@ -7,6 +7,8 @@
|
||||

|
||||
[](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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
12
src/check.rs
12
src/check.rs
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { theme } from "../theme";
|
||||
export default function Badge({ from, to }: { from: string; to: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full bg-${theme}-50 px-2 py-1 text-xs font-medium text-${theme}-700 ring-1 ring-inset ring-${theme}-700/10 dark:bg-${theme}-400/10 dark:text-${theme}-400 dark:ring-${theme}-400/30 break-keep`}
|
||||
className={`hidden sm:block inline-flex items-center rounded-full bg-${theme}-50 px-2 py-1 text-xs font-medium text-${theme}-700 ring-1 ring-inset ring-${theme}-700/10 dark:bg-${theme}-400/10 dark:text-${theme}-400 dark:ring-${theme}-400/30 break-keep`}
|
||||
>
|
||||
{from}
|
||||
<ArrowRight className="size-3" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
84
web/src/components/ui/Select.tsx
Normal file
84
web/src/components/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
)[];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
|
||||
Reference in New Issue
Block a user