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

27 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
26 changed files with 159 additions and 95 deletions

View File

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

View File

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

2
Cargo.lock generated
View File

@@ -355,7 +355,7 @@ dependencies = [
[[package]] [[package]]
name = "cup" name = "cup"
version = "3.2.0" version = "3.2.3"
dependencies = [ dependencies = [
"bollard", "bollard",
"chrono", "chrono",

View File

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

View File

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

View File

@@ -36,7 +36,7 @@
}, },
"refresh_interval": { "refresh_interval": {
"type": "string", "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 "minLength": 11
}, },
"registries": { "registries": {

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { Callout } from "nextra/components";
# Docker Compose # 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. 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" 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! 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

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

@@ -26,10 +26,10 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
let json_url = base_url.clone() + "json"; let json_url = base_url.clone() + "json";
if refresh { if refresh {
let refresh_url = base_url + "refresh"; let refresh_url = base_url + "refresh";
match client.get(&(&refresh_url), vec![], false).await { match client.get(&refresh_url, &[], false).await {
Ok(response) => { Ok(response) => {
if response.status() != 200 { if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}",refresh_url,response.status())); ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status()));
return Vec::new(); return Vec::new();
} }
}, },
@@ -40,10 +40,10 @@ 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) => { Ok(response) => {
if response.status() != 200 { if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}",json_url,response.status())); ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}", json_url, response.status()));
return Vec::new(); return Vec::new();
} }
let json = parse_json(&get_response_body(response).await); let json = parse_json(&get_response_body(response).await);
@@ -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. /// Returns a list of updates for all images passed in.
pub async fn get_updates( 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, refresh: bool,
ctx: &Context, ctx: &Context,
) -> Vec<Update> { ) -> Vec<Update> {
let client = Client::new(ctx); 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 // Get local images
ctx.logger.debug("Retrieving images to be checked"); ctx.logger.debug("Retrieving images to be checked");
let mut images = get_images_from_docker_daemon(ctx, references).await; let mut images = get_images_from_docker_daemon(ctx, references).await;
// Add extra images from references // 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 image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect();
let extra = refs let extra = all_references
.iter() .iter()
.filter(|&reference| !image_refs.contains(reference)) .filter(|&reference| !image_refs.contains(reference))
.map(|reference| Image::from_reference(reference)) .map(|reference| Image::from_reference(reference))

View File

@@ -124,11 +124,11 @@ pub fn print_updates(updates: &[Update], icons: &bool) {
Status::Unknown(_) => "\x1b[90m", Status::Unknown(_) => "\x1b[90m",
}; };
let description = format!( let description = format!(
"{} {}", "{}{}",
status, status,
match &update.result.info { match &update.result.info {
UpdateInfo::Version(info) => { UpdateInfo::Version(info) => {
format!("({}{})", info.current_version, info.new_version) format!(" ({}{})", info.current_version, info.new_version)
} }
_ => String::new(), _ => String::new(),
} }

View File

@@ -42,7 +42,7 @@ impl Client {
&self, &self,
url: &str, url: &str,
method: RequestMethod, method: RequestMethod,
headers: Vec<(&str, Option<&str>)>, headers: &[(&str, Option<&str>)],
ignore_401: bool, ignore_401: bool,
) -> Result<Response, String> { ) -> Result<Response, String> {
let mut request = match method { let mut request = match method {
@@ -51,7 +51,7 @@ impl Client {
}; };
for (name, value) in headers { for (name, value) in headers {
if let Some(v) = value { if let Some(v) = value {
request = request.header(name, v) request = request.header(*name, *v)
} }
} }
match request.send().await { match request.send().await {
@@ -114,7 +114,7 @@ impl Client {
pub async fn get( pub async fn get(
&self, &self,
url: &str, url: &str,
headers: Vec<(&str, Option<&str>)>, headers: &[(&str, Option<&str>)],
ignore_401: bool, ignore_401: bool,
) -> Result<Response, String> { ) -> Result<Response, String> {
self.request(url, RequestMethod::GET, headers, ignore_401) self.request(url, RequestMethod::GET, headers, ignore_401)
@@ -124,7 +124,7 @@ impl Client {
pub async fn head( pub async fn head(
&self, &self,
url: &str, url: &str,
headers: Vec<(&str, Option<&str>)>, headers: &[(&str, Option<&str>)],
) -> Result<Response, String> { ) -> Result<Response, String> {
self.request(url, RequestMethod::HEAD, headers, false).await 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> { pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Option<String> {
let protocol = get_protocol(registry, &ctx.config.registries); let protocol = get_protocol(registry, &ctx.config.registries);
let url = format!("{}://{}/v2/", protocol, registry); let url = format!("{}://{}/v2/", protocol, registry);
let response = client.get(&url, Vec::new(), true).await; let response = client.get(&url, &[], true).await;
match response { match response {
Ok(response) => { Ok(response) => {
let status = response.status(); let status = response.status();
@@ -57,9 +57,9 @@ pub async fn get_latest_digest(
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
); );
let authorization = to_bearer_string(&token); 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; let time = start.elapsed().unwrap().as_millis() as u32;
ctx.logger.debug(format!( ctx.logger.debug(format!(
"Checked for digest update to {} in {}ms", "Checked for digest update to {} in {}ms",
@@ -95,7 +95,7 @@ pub async fn get_latest_digest(
} }
pub async fn get_token( pub async fn get_token(
images: &Vec<&Image>, images: &[&Image],
auth_url: &str, auth_url: &str,
credentials: &Option<String>, credentials: &Option<String>,
client: &Client, client: &Client,
@@ -105,9 +105,9 @@ pub async fn get_token(
url = format!("{}&scope=repository:{}:pull", url, image.parts.repository); url = format!("{}&scope=repository:{}:pull", url, image.parts.repository);
} }
let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds)); 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 { let response_json = match response {
Ok(response) => parse_json(&get_response_body(response).await), Ok(response) => parse_json(&get_response_body(response).await),
Err(_) => error!("GET {}: Request failed!", url), Err(_) => error!("GET {}: Request failed!", url),
@@ -131,7 +131,7 @@ pub async fn get_latest_tag(
protocol, &image.parts.registry, &image.parts.repository, protocol, &image.parts.registry, &image.parts.repository,
); );
let authorization = to_bearer_string(&token); let authorization = to_bearer_string(&token);
let headers = vec![ let headers = [
("Accept", Some("application/json")), ("Accept", Some("application/json")),
("Authorization", authorization.as_deref()), ("Authorization", authorization.as_deref()),
]; ];
@@ -147,7 +147,7 @@ pub async fn get_latest_tag(
)); ));
let (new_tags, next) = match get_extra_tags( let (new_tags, next) = match get_extra_tags(
&next_url.unwrap(), &next_url.unwrap(),
headers.clone(), &headers,
base, base,
&image.version_info.as_ref().unwrap().format_str, &image.version_info.as_ref().unwrap().format_str,
client, client,
@@ -182,10 +182,7 @@ pub async fn get_latest_tag(
)); ));
get_latest_digest( get_latest_digest(
&Image { &Image {
version_info: Some(VersionInfo { 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)
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
time_ms: image.time_ms + elapsed(start), time_ms: image.time_ms + elapsed(start),
..image.clone() ..image.clone()
}, },
@@ -205,18 +202,21 @@ pub async fn get_latest_tag(
} }
} }
} }
None => error!("Image {} has no remote version tags! Local tag: {}", image.reference, image.parts.tag), None => error!(
"Image {} has no remote version tags! Local tag: {}",
image.reference, image.parts.tag
),
} }
} }
pub async fn get_extra_tags( pub async fn get_extra_tags(
url: &str, url: &str,
headers: Vec<(&str, Option<&str>)>, headers: &[(&str, Option<&str>)],
base: &Version, base: &Version,
format_str: &str, format_str: &str,
client: &Client, client: &Client,
) -> Result<(Vec<Version>, Option<String>), String> { ) -> Result<(Vec<Version>, Option<String>), String> {
let response = client.get(url, headers, false).await; let response = client.get(url, &headers, false).await;
match response { match response {
Ok(res) => { Ok(res) => {

View File

@@ -8,6 +8,7 @@ use tokio::sync::Mutex;
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio_cron_scheduler::{Job, JobScheduler};
use xitca_web::{ use xitca_web::{
body::ResponseBody, body::ResponseBody,
bytes::Bytes,
error::Error, error::Error,
handler::{handler_service, path::PathRef, state::StateRef}, handler::{handler_service, path::PathRef, state::StateRef},
http::{StatusCode, WebResponse}, http::{StatusCode, WebResponse},
@@ -32,9 +33,9 @@ use crate::{
const HTML: &str = include_str!("static/index.html"); const HTML: &str = include_str!("static/index.html");
const JS: &str = include_str!("static/assets/index.js"); const JS: &str = include_str!("static/assets/index.js");
const CSS: &str = include_str!("static/assets/index.css"); const CSS: &str = include_str!("static/assets/index.css");
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico"); const FAVICON_ICO: Bytes = Bytes::from_static(include_bytes!("static/favicon.ico"));
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg"); const FAVICON_SVG: Bytes = Bytes::from_static(include_bytes!("static/favicon.svg"));
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png"); const APPLE_TOUCH_ICON: Bytes = Bytes::from_static(include_bytes!("static/apple-touch-icon.png"));
const SORT_ORDER: [&str; 8] = [ const SORT_ORDER: [&str; 8] = [
"monitored_images", "monitored_images",

View File

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

View File

@@ -8,15 +8,24 @@ pub fn split(reference: &str) -> (String, String, String) {
0 => unreachable!(), 0 => unreachable!(),
1 => (DEFAULT_REGISTRY, reference.to_string()), 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 // 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("/")) (splits[0], splits[1..].join("/"))
} else { } else {
(DEFAULT_REGISTRY, reference.to_string()) (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() { let (repository, tag) = match splits.len() {
1 | 2 => { 1 | 2 => {
let repository_components = splits[0].split('/').collect::<Vec<&str>>(); let repository_components = splits[0].split('/').collect::<Vec<&str>>();
@@ -38,7 +47,9 @@ pub fn split(reference: &str) -> (String, String, String) {
}; };
(repository, tag) (repository, tag)
} }
_ => {dbg!(splits); panic!()}, _ => {
panic!("Failed to parse reference! Splits: {:?}", splits)
}
}; };
(registry.to_string(), repository, tag.to_string()) (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("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("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("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: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: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" ))); 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

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

View File

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

@@ -18,6 +18,7 @@ import {
TriangleAlert, TriangleAlert,
X, X,
} from "lucide-react"; } from "lucide-react";
import Badge from "./Badge";
const clickable_registries = [ const clickable_registries = [
"registry-1.docker.io", "registry-1.docker.io",
@@ -60,12 +61,20 @@ export default function Image({ data }: { data: Image }) {
> >
<Box className={`size-6 shrink-0 text-${theme}-500`} /> <Box className={`size-6 shrink-0 text-${theme}-500`} />
<span className="font-mono">{data.reference}</span> <span className="font-mono">{data.reference}</span>
<WithTooltip <div className="ml-auto flex gap-2">
text={info.description} {data.result.info?.type === "version" ? (
className={`ml-auto size-6 shrink-0 ${info.color}`} <Badge
> from={data.result.info.current_version}
<info.icon /> to={data.result.info.new_version}
</WithTooltip> />
) : null}
<WithTooltip
text={info.description}
className={`size-6 shrink-0 ${info.color}`}
>
<info.icon />
</WithTooltip>
</div>
</li> </li>
</button> </button>
<Dialog open={open} onClose={setOpen} className="relative z-10"> <Dialog open={open} onClose={setOpen} className="relative z-10">

View File

@@ -6,7 +6,7 @@ import { LoaderCircle } from "lucide-react";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) { export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
fetch( fetch(
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
? "/api/v3/json" ? "./api/v3/json"
: `http://${window.location.hostname}:8000/api/v3/json`, : `http://${window.location.hostname}:8000/api/v3/json`,
).then((response) => ).then((response) =>
response.json().then((data) => { response.json().then((data) => {
@@ -26,9 +26,9 @@ export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
<Logo /> <Logo />
</div> </div>
<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" /> Loading <LoaderCircle className="animate-spin" />
</div> </div>
<p> <p>

View File

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

View File

@@ -23,30 +23,30 @@ export default function Search({
onChange(""); onChange("");
}; };
return ( return (
<div className={`w-full px-6 text-black dark:text-white`}> <div
<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 group relative 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 peer flex-nowrap`} >
> <SearchIcon
<SearchIcon className={`size-5 text-${theme}-600 dark:text-${theme}-400`} /> className={`size-5 text-${theme}-600 dark:text-${theme}-400`}
<div className="w-full"> />
<input <div className="w-full">
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`} <input
placeholder="Search" 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`}
onChange={handleChange} placeholder="Search"
value={searchQuery} onChange={handleChange}
></input> value={searchQuery}
</div> ></input>
{showClear && (
<button
onClick={handleClear}
className={`hover:text-${theme}-600 dark:hover:text-${theme}-400 transition-colors duration-200`}
>
<X className="size-5" />
</button>
)}
</div> </div>
{showClear && (
<button
onClick={handleClear}
className={`hover:text-${theme}-600 dark:hover:text-${theme}-400 transition-colors duration-200`}
>
<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)" }} style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
></div> ></div>
</div> </div>

View File

@@ -15,7 +15,7 @@ export default {
variants: ["hover"], variants: ["hover"],
}, },
{ {
pattern: /bg-(gray|neutral)-(900|950)/, pattern: /bg-(gray|neutral)-(400|900|950)/,
variants: ["dark"], variants: ["dark"],
}, },
{ {
@@ -27,24 +27,16 @@ export default {
variants: ["before:dark", "after:dark", "dark", "hover:dark"], 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"], variants: ["dark"],
}, },
{ {
pattern: /text-(gray|neutral)-600/, pattern: /text-(gray|neutral)-600/,
variants: ["dark", "hover"], variants: ["*", "dark", "hover", "placeholder"],
}, },
{ {
pattern: /text-(gray|neutral)-400/, pattern: /text-(gray|neutral)-400/,
variants: ["dark", "dark:hover"], variants: ["*:dark", "dark", "dark:hover", "placeholder:dark"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["placeholder"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["placeholder:dark"],
}, },
{ {
pattern: /text-(gray|neutral)-700/, pattern: /text-(gray|neutral)-700/,
@@ -68,5 +60,12 @@ export default {
pattern: /border-(gray|neutral)-(700|800|900)/, pattern: /border-(gray|neutral)-(700|800|900)/,
variants: ["dark"], 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: "./",
build: { build: {
rollupOptions: { rollupOptions: {
// https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943 // https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943