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

50 Commits

Author SHA1 Message Date
Sergio
efea81ef39 chore: bump project version 2025-04-10 19:22:19 +03:00
Pavel
d3cb5af225 Fix refresh button when using custom base url (#89) 2025-04-07 07:11:44 +03:00
Sergio
5904c2d2e2 fix: ignore version info when tags are equal
Even though some images had newer digests, they weren't being taken into
consideration when checking for updates. Should resolve #85 (further
testing needed to confirm).
2025-04-06 20:10:05 +03:00
Sergio
674bc3d614 fix: misaligned table columns in CLI
Reported in #85
2025-04-04 16:09:31 +03:00
Seow Alex
e4a07f9810 fix: use default registry for docker.io (#86) 2025-04-03 22:17:50 +03:00
dependabot[bot]
4e0f3c3eb9 chore(deps): bump next from 15.2.3 to 15.2.4 in /docs (#87)
Bumps [next](https://github.com/vercel/next.js) from 15.2.3 to 15.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.2.3...v15.2.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 16:32:54 +03:00
Sergio
ba20dd3086 docs: mention seconds are required in cron pattern 2025-04-03 15:24:50 +03:00
Sergio
86d5b0465c chore: bump project version 2025-03-26 17:52:24 +02:00
Sergio
9d358ca6b2 fix: prevent wrapping text in badges 2025-03-26 17:51:21 +02:00
Sergio
f886601185 fix: check extra references specified in config
Fixes #81
2025-03-26 16:57:33 +02:00
Sergio
806364f01d ci: fix incorrect nightly workflow 2025-03-25 16:44:10 +02:00
Sergio
d35759ec66 chore: bump project version 2025-03-25 15:44:10 +02:00
Sergio
ffefe1db38 fix: specify color scheme in the web UI
Fixes a bug when displaying dark mode in Chrome
2025-03-25 15:44:10 +02:00
dependabot[bot]
2f9efe22d4 chore(deps): bump next from 15.1.5 to 15.2.3 in /docs (#80)
Bumps [next](https://github.com/vercel/next.js) from 15.1.5 to 15.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.1.5...v15.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 09:35:06 +02:00
Thomas McWork
bbfb3c63ea docs: add instructions to find the docker group id to the compose docs 2025-03-21 18:47:59 +02:00
Sergio
6800f1ae27 refactor: specify the exposed port in the CI Dockerfile 2025-03-21 18:38:37 +02:00
Thomas McWork
402d72c85b refactor: specify the exposed port in the Dockerfile 2025-03-21 18:36:52 +02:00
Sergio
4f54301467 docs: fix incorrect edit this page URL
Closes #79
2025-03-21 18:20:14 +02:00
Sergio
be99438123 refactor: search component 2025-03-21 18:17:05 +02:00
Sergio
71164417a0 style: format code 2025-03-21 16:55:28 +02:00
Sergio
59ca170592 refactor: fix lint error in web UI 2025-03-21 16:54:52 +02:00
Sergio
b37b7ed060 refactor: load web UI assets with a relative URL to allow for hosting under a different path.
Might fix #53.
2025-03-21 16:32:23 +02:00
Sergio
dd68c5097a feat: add badges to web UI to quickly show which version is running and which the user will upgrade to
This is an example of a bad, long commit message.
2025-03-21 15:57:49 +02:00
Sergio
5fbbba32f1 refactor: remove dbg and use a proper panic when parsing a reference 2025-03-19 19:28:31 +02:00
Sergio
b10af38df4 chore: format code 2025-03-19 19:24:19 +02:00
Sergio
77a07013a9 refactor: use array slices instead of vectors wherever possible 2025-03-19 19:20:29 +02:00
Sergio
ccf825df24 refactor: use Bytes to store constant blobs in the server
Might also improve performance.
2025-03-19 13:56:47 +02:00
Sergio
e26f941c59 chore: bump project version 2025-03-16 18:42:06 +02:00
Sergio
c411fc4bad fix: ignore invalid digests instead of panicking 2025-03-16 18:40:10 +02:00
Sergio
e965380133 fix: improve error handling in get_latest_tag when an image has no tags
This commit is mostly for debugging #68, but it's good to have more
error info just in case.
2025-03-16 18:33:19 +02:00
Sergio
ef849b624f fix: don't pass empty parameters when making auth request (#69) 2025-03-16 18:26:04 +02:00
Sergio
8db7e2e12b fix: improve error handling when scheduling automatic refresh 2025-03-15 12:28:39 +02:00
Sergio
54e1998032 docs: fix incorrect cron schedule example 2025-03-15 12:28:39 +02:00
Sergio
9f142ab81c fix: add error message when app fails to bind to port 2025-03-15 12:28:39 +02:00
Sergio
ffd4d6267c chore: update readme
forgot to add a link
2025-03-14 10:20:51 +02:00
Sergio
242029db22 chore: update readme 2025-03-14 10:15:38 +02:00
Sergio
b6562ef76f chore: bump project version 2025-03-13 17:16:22 +02:00
Sergio
846b24bf2d feat: add experimental docker swarm support 2025-03-13 17:09:20 +02:00
Sergio
d7f766f1f5 chore(readme): Add discord link 2025-03-11 17:15:11 +02:00
Sergio
60096792a9 chore(docs): update JSON API reference schema 2025-03-11 17:08:42 +02:00
Sergio
a599d4e084 feat: detect image url from label 2025-03-11 17:06:00 +02:00
Sergio
a5a1f12899 chore: remove unneeded file 2025-03-11 17:05:46 +02:00
Sergio
766a20ccac chore: add badges to readme 2025-03-11 16:32:59 +02:00
Sergio
fe66ba842a docs: add docs about refresh endpoint 2025-03-11 16:26:48 +02:00
Sergio
c06c20394f docs: add page about home assistant integration 2025-03-11 16:26:31 +02:00
Sergio
98dafb8ba4 chore: bump project version 2025-03-10 16:49:07 +02:00
Sergio
2addfca1b4 fix: code block component copying was working incorrectly (#62) 2025-03-10 16:47:47 +02:00
dependabot[bot]
e3b05923ae Bump ring from 0.17.11 to 0.17.13 (#60)
Bumps [ring](https://github.com/briansmith/ring) from 0.17.11 to 0.17.13.
- [Changelog](https://github.com/briansmith/ring/blob/main/RELEASES.md)
- [Commits](https://github.com/briansmith/ring/commits)

---
updated-dependencies:
- dependency-name: ring
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-07 19:26:28 +02:00
Sergio
aa4195f8d6 chore: bump project version 2025-03-07 18:25:00 +02:00
Sergio
1b94629c79 fix: overflowing image references in web UI 2025-03-07 18:01:32 +02:00
39 changed files with 353 additions and 131 deletions

View File

@@ -9,4 +9,5 @@ RUN chmod +x cup
FROM scratch
COPY --from=builder /cup /cup
EXPOSE 8000
ENTRYPOINT ["/cup"]

View File

@@ -8,7 +8,7 @@ inputs:
required: true
runs:
using: 'composite'
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -47,5 +47,6 @@ runs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -78,6 +78,7 @@ jobs:
nightly-release:
runs-on: ubuntu-latest
needs:
- get-tag
- build-binaries
- build-image
steps:

6
Cargo.lock generated
View File

@@ -355,7 +355,7 @@ dependencies = [
[[package]]
name = "cup"
version = "3.0.2"
version = "3.2.3"
dependencies = [
"bollard",
"chrono",
@@ -1505,9 +1505,9 @@ dependencies = [
[[package]]
name = "ring"
version = "0.17.11"
version = "0.17.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73"
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
dependencies = [
"cc",
"cfg-if",

View File

@@ -1,6 +1,6 @@
[package]
name = "cup"
version = "3.0.2"
version = "3.2.3"
edition = "2021"
[dependencies]

View File

@@ -39,4 +39,5 @@ FROM scratch
# Copy binary
COPY --from=build /cup/target/release/cup /cup
EXPOSE 8000
ENTRYPOINT ["/cup"]

View File

@@ -1,5 +1,13 @@
# Cup 🥤
![GitHub License](https://img.shields.io/github/license/sergi0g/cup)
![CI Status](https://img.shields.io/github/actions/workflow/status/sergi0g/cup/.github%2Fworkflows%2Fci.yml?label=CI)
![GitHub last commit](https://img.shields.io/github/last-commit/sergi0g/cup)
![GitHub Release](https://img.shields.io/github/v/release/sergi0g/cup)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/sergi0g/cup)
[![Discord](https://img.shields.io/discord/1337705080518086658)](https://discord.gg/jmh5ctzwNG)
Cup is the easiest way to check for container image updates.
![Cup web in dark mode](screenshots/web_dark.png)
@@ -15,7 +23,7 @@ _If you like this project and/or use Cup, please consider starring the project
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my Raspberry Pi 5, it took 3.7 seconds for 58 images!
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/getwud/wud) which would always use it up.
- Doesn't exhaust any rate limits. This is the original reason I created Cup. I feel that this feature is especially relevant now with [Docker Hub reducing its pull limits for unauthenticated users](https://docs.docker.com/docker-hub/usage/).
- Beautiful CLI and web interface for checking on your containers any time.
- The binary is tiny! At the time of writing it's just 5.4 MB. No more pulling 100+ MB docker images for a such a simple program.
- JSON output for both the CLI and web interface so you can connect Cup to integrations. It's easy to parse and makes webhooks and pretty dashboards simple to set up!
@@ -48,7 +56,7 @@ For more information, check the [docs](https://cup.sergi0g.dev/docs/contributing
## Support
If you have any questions about Cup, feel free to ask in the [discussions](https://github.com/sergi0g/cup/discussions)!
If you have any questions about Cup, feel free to ask in the [discussions](https://github.com/sergi0g/cup/discussions)! You can also join our [discord server](https://discord.gg/jmh5ctzwNG).
If you find a bug, or want to propose a feature, search for it in the [issues](https://github.com/sergi0g/cup/issues). If there isn't already an open issue, please open one.

View File

@@ -36,7 +36,7 @@
},
"refresh_interval": {
"type": "string",
"description": "The interval at which Cup should check for updates. Must be a valid cron expression. Reference: https://github.com/Hexagon/croner-rust#pattern",
"description": "The interval at which Cup should check for updates. Must be a valid cron expression. Seconds are not optional. Reference: https://github.com/Hexagon/croner-rust#pattern",
"minLength": 11
},
"registries": {

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@tabler/icons-react": "^3.29.0",
"geist": "^1.3.1",
"next": "15.1.5",
"next": "15.2.4",
"nextra": "^4.1.0",
"nextra-theme-docs": "^4.1.0",
"react": "^19.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -45,7 +45,7 @@ export default async function RootLayout({
navbar={navbar}
pageMap={await getPageMap()}
footer={footer}
docsRepositoryBase="https://github.com/sergi0g/cup"
docsRepositoryBase="https://github.com/sergi0g/cup/blob/main/docs"
>
<div>{children}</div>
</Layout>

View File

@@ -1,3 +1,5 @@
import { Callout } from "nextra/components";
# Docker Compose
Many users find it useful to run Cup with Docker Compose, as it enables them to have it constantly running in the background and easily control it. Cup's lightweight resource use makes it ideal for this use case.
@@ -40,4 +42,8 @@ Cup can run with a non-root user, but needs to be in a docker group. Assuming us
user: "1000:999"
```
<Callout>
You can use the command `getent group docker | cut -d: -f3` to find the group id for the docker group.
</Callout>
The compose can be customized further of course, if you choose to use a different port, another config location, or would like to change something else. Have fun!

View File

@@ -0,0 +1,47 @@
import Image from "next/image";
import screenshot from "@/app/assets/ha-cup-component.png";
# Home Assistant integration
Many thanks to [@bastgau](https://github.com/bastgau) for creating this integration.
## About
The **HA Cup Component** integration for Home Assistant allows you to retrieve update statistics for Docker containers directly from your Home Assistant interface.
With this integration, you can easily track the status of your Docker containers and receive notifications when updates are available.
The following sensors are currently implemented:
<Image
src={screenshot}
alt="Screenshot of Home Assistant showing a card with update information provided by Cup"
/>
## Installation
### Via HACS
1. Open Home Assistant and go to HACS
2. Navigate to "Integrations" and click on "Add a custom repository".
3. Use https://github.com/bastgau/ha-cup-component as the URL
4. Search for "HA Cup Component" and install it.
5. Restart Home Assistant.
### One-click install
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bastgau&repository=ha-cup-component&category=Integration)
### Manual Installation
1. Download the integration files from the GitHub repository.
2. Place the integration folder in the custom_components directory of Home Assistant.
3. Restart Home Assistant.
## Support & Contributions
If you encounter any issues or wish to contribute to improving this integration, feel free to open an issue or a pull request in the [GitHub repository](https://github.com/bastgau/ha-cup-component).
Support the author:
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bastgau)

View File

@@ -4,9 +4,9 @@ Cup can automatically refresh the results when running in server mode. Simply ad
```jsonc
{
"refresh_interval": "0 0,30 * 0 0" // Check twice an hour
"refresh_interval": "0 */30 * * * *", // Check twice an hour
// Other options
}
```
You can use a cron expression to specify the refresh interval. 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)

View File

@@ -1,5 +1,5 @@
import { Callout, Cards } from "nextra/components";
import { IconServer, IconTerminal } from "@tabler/icons-react"
import { IconServer, IconTerminal } from "@tabler/icons-react";
# Integrations
@@ -34,6 +34,7 @@ The data returned from the API or from the CLI is in JSON and looks like this:
"repository": "sergi0g/cup",
"tag": "latest",
},
"url": "https://github.com/sergi0g/cup", // The URL specified in the "org.opencontainers.image.url" label, otherwise null
"result": {
"has_update": true, // `true` when an image has an update of any kind, `false` when up to date and `null` when unknown.
"info": {
@@ -77,3 +78,7 @@ For retrieving the above data, refer to the CLI and server pages:
href="/docs/usage/server"
/>
</Cards>
## Refresh Cup
If you'd like to fetch the latest information, you can manually trigger a refresh by making a `GET` request to the `/api/v3/refresh` endpoint. Once the request completes, you can fetch the data as described above.

View File

@@ -26,7 +26,7 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
let json_url = base_url.clone() + "json";
if refresh {
let refresh_url = base_url + "refresh";
match client.get(&(&refresh_url), vec![], false).await {
match client.get(&refresh_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status()));
@@ -40,7 +40,7 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
}
}
match client.get(&json_url, vec![], false).await {
match client.get(&json_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}", json_url, response.status()));
@@ -81,20 +81,29 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
/// Returns a list of updates for all images passed in.
pub async fn get_updates(
references: &Option<Vec<String>>,
references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value
refresh: bool,
ctx: &Context,
) -> Vec<Update> {
let client = Client::new(ctx);
// Merge references argument with references from config
let all_references = match &references {
Some(refs) => {
refs.clone().extend_from_slice(&ctx.config.images.extra);
refs
}
None => &ctx.config.images.extra,
};
// Get local images
ctx.logger.debug("Retrieving images to be checked");
let mut images = get_images_from_docker_daemon(ctx, references).await;
// Add extra images from references
if let Some(refs) = references {
if !all_references.is_empty() {
let image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect();
let extra = refs
let extra = all_references
.iter()
.filter(|&reference| !image_refs.contains(reference))
.map(|reference| Image::from_reference(reference))

View File

@@ -42,7 +42,26 @@ pub async fn get_images_from_docker_daemon(
references: &Option<Vec<String>>,
) -> Vec<Image> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
match references {
let mut swarm_images = match client.list_services::<String>(None).await {
Ok(services) => services
.iter()
.filter_map(|service| match &service.spec {
Some(service_spec) => match &service_spec.task_template {
Some(task_spec) => match &task_spec.container_spec {
Some(container_spec) => match &container_spec.image {
Some(image) => Image::from_inspect_data(ctx, image),
None => None,
},
None => None,
},
None => None,
},
None => None,
})
.collect(),
Err(_) => Vec::new(),
};
let mut local_images = match references {
Some(refs) => {
let mut inspect_handles = Vec::with_capacity(refs.len());
for reference in refs {
@@ -56,7 +75,7 @@ pub async fn get_images_from_docker_daemon(
.collect();
inspects
.iter()
.filter_map(|inspect| Image::from_inspect_data(inspect.clone()))
.filter_map(|inspect| Image::from_inspect_data(ctx, inspect.clone()))
.collect()
}
None => {
@@ -68,8 +87,10 @@ pub async fn get_images_from_docker_daemon(
};
images
.iter()
.filter_map(|image| Image::from_inspect_data(image.clone()))
.collect()
}
.filter_map(|image| Image::from_inspect_data(ctx, image.clone()))
.collect::<Vec<Image>>()
}
};
local_images.append(&mut swarm_images);
local_images
}

View File

@@ -42,7 +42,7 @@ impl Client {
&self,
url: &str,
method: RequestMethod,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
let mut request = match method {
@@ -51,7 +51,7 @@ impl Client {
};
for (name, value) in headers {
if let Some(v) = value {
request = request.header(name, v)
request = request.header(*name, *v)
}
}
match request.send().await {
@@ -114,7 +114,7 @@ impl Client {
pub async fn get(
&self,
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
self.request(url, RequestMethod::GET, headers, ignore_401)
@@ -124,7 +124,7 @@ impl Client {
pub async fn head(
&self,
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
) -> Result<Response, String> {
self.request(url, RequestMethod::HEAD, headers, false).await
}

View File

@@ -22,7 +22,7 @@ use crate::{
pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Option<String> {
let protocol = get_protocol(registry, &ctx.config.registries);
let url = format!("{}://{}/v2/", protocol, registry);
let response = client.get(&url, Vec::new(), true).await;
let response = client.get(&url, &[], true).await;
match response {
Ok(response) => {
let status = response.status();
@@ -57,9 +57,9 @@ pub async fn get_latest_digest(
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
);
let authorization = to_bearer_string(&token);
let headers = vec![("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")), ("Authorization", authorization.as_deref())];
let response = client.head(&url, headers).await;
let response = client.head(&url, &headers).await;
let time = start.elapsed().unwrap().as_millis() as u32;
ctx.logger.debug(format!(
"Checked for digest update to {} in {}ms",
@@ -95,7 +95,7 @@ pub async fn get_latest_digest(
}
pub async fn get_token(
images: &Vec<&Image>,
images: &[&Image],
auth_url: &str,
credentials: &Option<String>,
client: &Client,
@@ -105,9 +105,9 @@ pub async fn get_token(
url = format!("{}&scope=repository:{}:pull", url, image.parts.repository);
}
let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds));
let headers = vec![("Authorization", authorization.as_deref())];
let headers = [("Authorization", authorization.as_deref())];
let response = client.get(&url, headers, false).await;
let response = client.get(&url, &headers, false).await;
let response_json = match response {
Ok(response) => parse_json(&get_response_body(response).await),
Err(_) => error!("GET {}: Request failed!", url),
@@ -131,7 +131,7 @@ pub async fn get_latest_tag(
protocol, &image.parts.registry, &image.parts.repository,
);
let authorization = to_bearer_string(&token);
let headers = vec![
let headers = [
("Accept", Some("application/json")),
("Authorization", authorization.as_deref()),
];
@@ -147,7 +147,7 @@ pub async fn get_latest_tag(
));
let (new_tags, next) = match get_extra_tags(
&next_url.unwrap(),
headers.clone(),
&headers,
base,
&image.version_info.as_ref().unwrap().format_str,
client,
@@ -182,10 +182,7 @@ pub async fn get_latest_tag(
));
get_latest_digest(
&Image {
version_info: Some(VersionInfo {
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
version_info: None, // Overwrite previous version info, since it isn't useful anymore (equal tags means up to date and an image is truly up to date when its digests are up to date, and we'll be checking those anyway)
time_ms: image.time_ms + elapsed(start),
..image.clone()
},
@@ -205,18 +202,21 @@ pub async fn get_latest_tag(
}
}
}
None => unreachable!("{:?}", tags),
None => error!(
"Image {} has no remote version tags! Local tag: {}",
image.reference, image.parts.tag
),
}
}
pub async fn get_extra_tags(
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
base: &Version,
format_str: &str,
client: &Client,
) -> Result<(Vec<Version>, Option<String>), String> {
let response = client.get(url, headers, false).await;
let response = client.get(url, &headers, false).await;
match response {
Ok(res) => {

View File

@@ -8,6 +8,7 @@ use tokio::sync::Mutex;
use tokio_cron_scheduler::{Job, JobScheduler};
use xitca_web::{
body::ResponseBody,
bytes::Bytes,
error::Error,
handler::{handler_service, path::PathRef, state::StateRef},
http::{StatusCode, WebResponse},
@@ -19,6 +20,7 @@ use xitca_web::{
use crate::{
check::get_updates,
config::Theme,
error,
structs::update::Update,
utils::{
json::{to_full_json, to_simple_json},
@@ -31,9 +33,9 @@ use crate::{
const HTML: &str = include_str!("static/index.html");
const JS: &str = include_str!("static/assets/index.js");
const CSS: &str = include_str!("static/assets/index.css");
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
const FAVICON_ICO: Bytes = Bytes::from_static(include_bytes!("static/favicon.ico"));
const FAVICON_SVG: Bytes = Bytes::from_static(include_bytes!("static/favicon.svg"));
const APPLE_TOUCH_ICON: Bytes = Bytes::from_static(include_bytes!("static/apple-touch-icon.png"));
const SORT_ORDER: [&str; 8] = [
"monitored_images",
@@ -55,13 +57,24 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
if let Some(interval) = &ctx.config.refresh_interval {
scheduler
.add(
Job::new_async(interval, move |_uuid, _lock| {
match Job::new_async(interval, move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.lock().await.refresh().await;
})
})
.unwrap(),
}) {
Ok(job) => job,
Err(e) => match e {
tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!(
"Failed to parse cron schedule: {}. Please ensure it is valid!",
interval
),
e => error!(
"An unexpected error occured while scheduling automatic refresh: {}",
e
),
},
},
)
.await
.unwrap();
@@ -79,10 +92,14 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
.at("/", get(handler_service(_static)))
.at("/*", get(handler_service(_static)));
}
app_builder
match app_builder
.enclosed_fn(logger)
.serve()
.bind(format!("0.0.0.0:{}", port))?
.bind(format!("0.0.0.0:{}", port))
{
Ok(r) => r,
Err(_) => error!("Failed to bind to port {}. Is it in use?", port),
}
.run()
.wait()
}

View File

@@ -35,6 +35,7 @@ pub struct VersionInfo {
pub struct Image {
pub reference: String,
pub parts: Parts,
pub url: Option<String>,
pub digest_info: Option<DigestInfo>,
pub version_info: Option<VersionInfo>,
pub error: Option<String>,
@@ -43,16 +44,30 @@ pub struct Image {
impl Image {
/// Creates and populates the fields of an Image object based on the ImageSummary from the Docker daemon
pub fn from_inspect_data<T: InspectData>(image: T) -> Option<Self> {
pub fn from_inspect_data<T: InspectData>(ctx: &Context, image: T) -> Option<Self> {
let tags = image.tags().unwrap();
let digests = image.digests().unwrap();
if !tags.is_empty() && !digests.is_empty() {
let reference = tags[0].clone();
if reference.contains('@') {
return None; // As far as I know, references that contain @ are either manually pulled by the user or automatically created because of swarm. In the first case AFAICT we can't know what tag was originally pulled, so we'd have to make assumptions and I've decided to remove this. The other case is already handled seperately, so this also ensures images aren't displayed twice, once with and once without a digest.
};
let (registry, repository, tag) = split(&reference);
let version_tag = Version::from_tag(&tag);
let local_digests = digests
.iter()
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
.filter_map(
|digest| match digest.split('@').collect::<Vec<&str>>().get(1) {
Some(digest) => Some(digest.to_string()),
None => {
ctx.logger.warn(format!(
"Ignoring invalid digest {} for image {}!",
digest, reference
));
None
}
},
)
.collect();
Some(Self {
reference,
@@ -61,6 +76,7 @@ impl Image {
repository,
tag,
},
url: image.url(),
digest_info: Some(DigestInfo {
local_digests,
remote_digest: None,
@@ -141,6 +157,7 @@ impl Image {
Update {
reference: self.reference.clone(),
parts: self.parts.clone(),
url: self.url.clone(),
result: UpdateResult {
has_update: has_update.to_option_bool(),
info: match has_update {

View File

@@ -1,26 +1,62 @@
use bollard::secret::{ImageInspect, ImageSummary};
pub trait InspectData {
fn tags(&self) -> Option<&Vec<String>>;
fn digests(&self) -> Option<&Vec<String>>;
fn tags(&self) -> Option<Vec<String>>;
fn digests(&self) -> Option<Vec<String>>;
fn url(&self) -> Option<String>;
}
impl InspectData for ImageInspect {
fn tags(&self) -> Option<&Vec<String>> {
self.repo_tags.as_ref()
fn tags(&self) -> Option<Vec<String>> {
self.repo_tags.clone()
}
fn digests(&self) -> Option<&Vec<String>> {
self.repo_digests.as_ref()
fn digests(&self) -> Option<Vec<String>> {
self.repo_digests.clone()
}
fn url(&self) -> Option<String> {
match &self.config {
Some(config) => match &config.labels {
Some(labels) => labels.get("org.opencontainers.image.url").cloned(),
None => None,
},
None => None,
}
}
}
impl InspectData for ImageSummary {
fn tags(&self) -> Option<&Vec<String>> {
Some(&self.repo_tags)
fn tags(&self) -> Option<Vec<String>> {
Some(self.repo_tags.clone())
}
fn digests(&self) -> Option<&Vec<String>> {
Some(&self.repo_digests)
fn digests(&self) -> Option<Vec<String>> {
Some(self.repo_digests.clone())
}
fn url(&self) -> Option<String> {
self.labels.get("org.opencontainers.image.url").cloned()
}
}
impl InspectData for &String {
fn tags(&self) -> Option<Vec<String>> {
self.split('@').next().map(|tag| vec![tag.to_string()])
}
fn digests(&self) -> Option<Vec<String>> {
match self.split_once('@') {
Some((reference, digest)) => Some(vec![format!(
"{}@{}",
reference.split(':').next().unwrap(),
digest
)]),
None => Some(vec![]),
}
}
fn url(&self) -> Option<String> {
None
}
}

View File

@@ -7,6 +7,7 @@ use super::{parts::Parts, status::Status};
pub struct Update {
pub reference: String,
pub parts: Parts,
pub url: Option<String>,
pub result: UpdateResult,
pub time: u32,
pub server: Option<String>,

View File

@@ -49,18 +49,24 @@ impl Version {
positions.push((major.start(), major.end()));
match major.as_str().parse() {
Ok(m) => m,
Err(_) => return None
Err(_) => return None,
}
}
None => return None,
};
let minor: Option<u32> = c.name("minor").map(|minor| {
positions.push((minor.start(), minor.end()));
minor.as_str().parse().unwrap_or_else(|_| panic!("Minor version invalid in tag {}", tag))
minor
.as_str()
.parse()
.unwrap_or_else(|_| panic!("Minor version invalid in tag {}", tag))
});
let patch: Option<u32> = c.name("patch").map(|patch| {
positions.push((patch.start(), patch.end()));
patch.as_str().parse().unwrap_or_else(|_| panic!("Patch version invalid in tag {}", tag))
patch
.as_str()
.parse()
.unwrap_or_else(|_| panic!("Patch version invalid in tag {}", tag))
});
let mut format_str = tag.to_string();
positions.reverse();

View File

@@ -8,15 +8,24 @@ pub fn split(reference: &str) -> (String, String, String) {
0 => unreachable!(),
1 => (DEFAULT_REGISTRY, reference.to_string()),
_ => {
// Check if the image is from Docker Hub
if splits[0] == "docker.io" {
(DEFAULT_REGISTRY, splits[1..].join("/"))
// Check if we're looking at a domain
if splits[0] == "localhost" || splits[0].contains('.') || splits[0].contains(':') {
} else if splits[0] == "localhost" || splits[0].contains('.') || splits[0].contains(':')
{
(splits[0], splits[1..].join("/"))
} else {
(DEFAULT_REGISTRY, reference.to_string())
}
}
};
let splits = repository_and_tag.split('@').next().unwrap().split(':').collect::<Vec<&str>>();
let splits = repository_and_tag
.split('@')
.next()
.unwrap()
.split(':')
.collect::<Vec<&str>>();
let (repository, tag) = match splits.len() {
1 | 2 => {
let repository_components = splits[0].split('/').collect::<Vec<&str>>();
@@ -38,7 +47,9 @@ pub fn split(reference: &str) -> (String, String, String) {
};
(repository, tag)
}
_ => {dbg!(splits); panic!()},
_ => {
panic!("Failed to parse reference! Splits: {:?}", splits)
}
};
(registry.to_string(), repository, tag.to_string())
}
@@ -57,6 +68,7 @@ mod tests {
assert_eq!(split("localhost:1234/test" ), (String::from("localhost:1234" ), String::from("test" ), String::from("latest")));
assert_eq!(split("test:1234/idk" ), (String::from("test:1234" ), String::from("idk" ), String::from("latest")));
assert_eq!(split("alpine:3.7" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("3.7" )));
assert_eq!(split("docker.io/library/alpine" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest")));
assert_eq!(split("docker.example.com/examplerepo/alpine:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine" ), String::from("3.7" )));
assert_eq!(split("docker.example.com/examplerepo/alpine/test2:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2" ), String::from("3.7" )));
assert_eq!(split("docker.example.com/examplerepo/alpine/test2/test3:3.7"), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2/test3"), String::from("3.7" )));

View File

@@ -17,8 +17,10 @@ pub fn parse_www_authenticate(www_auth: &str) -> String {
.fold(String::new(), |acc, (key, value)| {
if *key == "realm" {
acc.to_owned() + value.as_escaped() + "?"
} else {
} else if value.unescaped_len() != 0 {
format!("{}&{}={}", acc, key, value.as_escaped())
} else {
acc
}
})
} else {

View File

@@ -1 +0,0 @@
nodejs 22.8.0

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="color-scheme" content="light dark">
{% if theme == 'neutral' %}
<meta
name="theme-color"
@@ -26,9 +27,9 @@
content="#030712"
>
{% endif %}
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
<title>Cup</title>
</head>
<body>

View File

@@ -24,6 +24,7 @@ const SORT_ORDER = [
function App() {
const [data, setData] = useState<Data | null>(null);
const [searchQuery, setSearchQuery] = useState("");
if (!data) return <Loading onLoad={setData} />;
return (
<div
@@ -63,13 +64,15 @@ function App() {
<LastChecked datetime={data.last_updated} />
<RefreshButton />
</div>
<div className="flex gap-2 px-6 text-black dark:text-white">
<Search onChange={setSearchQuery} />
</div>
<ul>
{Object.entries(
data.images.reduce<Record<string, typeof data.images>>(
(acc, image) => {
const server = image.server ?? "";
if (!acc[server]) acc[server] = [];
if (!Object.hasOwn(acc, server)) acc[server] = [];
acc[server].push(image);
return acc;
},

View File

@@ -0,0 +1,14 @@
import { ArrowRight } from "lucide-react";
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`}
>
{from}
<ArrowRight className="size-3" />
{to}
</span>
);
}

View File

@@ -21,6 +21,8 @@ export function CodeBlock({
};
};
const copyText = children instanceof Array ? children.join("") : children;
return (
<div
className={`group relative flex w-full items-center rounded-lg bg-${theme}-100 px-3 py-2 font-mono text-${theme}-700 dark:bg-${theme}-950 dark:text-${theme}-300`}
@@ -35,7 +37,7 @@ export function CodeBlock({
) : (
<button
className={`duration-50 absolute right-3 bg-${theme}-100 py-1 pl-2 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-${theme}-950`}
onClick={handleCopy(`docker pull ${children}`)}
onClick={handleCopy(`${copyText}`)}
>
<Clipboard className="size-5" />
</button>

View File

@@ -18,6 +18,7 @@ import {
TriangleAlert,
X,
} from "lucide-react";
import Badge from "./Badge";
const clickable_registries = [
"registry-1.docker.io",
@@ -40,7 +41,9 @@ export default function Image({ data }: { data: Image }) {
: data.reference;
const info = getInfo(data)!;
let url: string | null = null;
if (clickable_registries.includes(data.parts.registry)) {
if (data.url) {
url = data.url;
} else if (clickable_registries.includes(data.parts.registry)) {
switch (data.parts.registry) {
case "registry-1.docker.io":
url = `https://hub.docker.com/r/${data.parts.repository}`;
@@ -58,12 +61,20 @@ export default function Image({ data }: { data: Image }) {
>
<Box className={`size-6 shrink-0 text-${theme}-500`} />
<span className="font-mono">{data.reference}</span>
<div className="ml-auto flex gap-2">
{data.result.info?.type === "version" ? (
<Badge
from={data.result.info.current_version}
to={data.result.info.new_version}
/>
) : null}
<WithTooltip
text={info.description}
className={`ml-auto size-6 shrink-0 ${info.color}`}
className={`size-6 shrink-0 ${info.color}`}
>
<info.icon />
</WithTooltip>
</div>
</li>
</button>
<Dialog open={open} onClose={setOpen} className="relative z-10">
@@ -82,14 +93,14 @@ export default function Image({ data }: { data: Image }) {
>
<div className="mb-4 flex items-center gap-3">
<Box className={`size-6 shrink-0 text-${theme}-500`} />
<DialogTitle className="font-mono text-black dark:text-white">
<DialogTitle className="break-all font-mono text-black dark:text-white">
{url ? (
<>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={`group w-fit text-black hover:underline dark:text-white`}
className={`group w-fit hover:underline`}
>
<span>
{data.reference}

View File

@@ -6,7 +6,7 @@ import { LoaderCircle } from "lucide-react";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
fetch(
process.env.NODE_ENV === "production"
? "/api/v3/json"
? "./api/v3/json"
: `http://${window.location.hostname}:8000/api/v3/json`,
).then((response) =>
response.json().then((data) => {
@@ -26,9 +26,9 @@ export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
<Logo />
</div>
<div
className={`flex flex-col h-full items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
className={`flex h-full flex-col items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
<div className="flex gap-1 mb-8">
<div className="mb-8 flex gap-1">
Loading <LoaderCircle className="animate-spin" />
</div>
<p>

View File

@@ -14,7 +14,7 @@ export default function RefreshButton() {
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/api/v3/refresh"
? "./api/v3/refresh"
: `http://${window.location.hostname}:8000/api/v3/refresh`,
);
request.send();

View File

@@ -23,11 +23,12 @@ export default function Search({
onChange("");
};
return (
<div className={`w-full px-6 text-black dark:text-white`}>
<div
className={`flex w-full items-center rounded-md border border-${theme}-200 dark:border-${theme}-700 gap-1 px-2 bg-${theme}-100 dark:bg-${theme}-900 peer flex-nowrap`}
className={`flex w-full items-center rounded-md border border-${theme}-200 dark:border-${theme}-700 gap-1 px-2 bg-${theme}-100 dark:bg-${theme}-900 group relative flex-nowrap`}
>
<SearchIcon className={`size-5 text-${theme}-600 dark:text-${theme}-400`} />
<SearchIcon
className={`size-5 text-${theme}-600 dark:text-${theme}-400`}
/>
<div className="w-full">
<input
className={`h-10 w-full text-sm text-${theme}-800 dark:text-${theme}-200 peer bg-transparent focus:outline-none placeholder:text-${theme}-600 placeholder:dark:text-${theme}-400`}
@@ -44,9 +45,8 @@ export default function Search({
<X className="size-5" />
</button>
)}
</div>
<div
className="relative left-1/2 h-[8px] w-0 -translate-x-1/2 -translate-y-[8px] rounded-md border-b-2 border-b-blue-600 transition-all duration-200 peer-has-[:focus]:w-full"
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-has-[:focus]:w-[calc(100%+2px)]"
style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
></div>
</div>

View File

@@ -20,6 +20,7 @@ export interface Image {
repository: string;
tag: string;
};
url: string | null;
result: {
has_update: boolean | null;
info: VersionInfo | DigestInfo | null;

View File

@@ -15,7 +15,7 @@ export default {
variants: ["hover"],
},
{
pattern: /bg-(gray|neutral)-(900|950)/,
pattern: /bg-(gray|neutral)-(400|900|950)/,
variants: ["dark"],
},
{
@@ -27,24 +27,16 @@ export default {
variants: ["before:dark", "after:dark", "dark", "hover:dark"],
},
{
pattern: /text-(gray|neutral)-(50|300|200)/,
pattern: /text-(gray|neutral)-(50|300|200|400)/,
variants: ["dark"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["dark", "hover"],
variants: ["*", "dark", "hover", "placeholder"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["dark", "dark:hover"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["placeholder"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["placeholder:dark"],
variants: ["*:dark", "dark", "dark:hover", "placeholder:dark"],
},
{
pattern: /text-(gray|neutral)-700/,
@@ -68,5 +60,12 @@ export default {
pattern: /border-(gray|neutral)-(700|800|900)/,
variants: ["dark"],
},
{
pattern: /ring-(gray|neutral)-700/,
},
{
pattern: /ring-(gray|neutral)-400/,
variants: ["dark"],
},
],
};

View File

@@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: "./",
build: {
rollupOptions: {
// https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943