mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-12 15:13:49 -05:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efea81ef39 | ||
|
|
d3cb5af225 | ||
|
|
5904c2d2e2 | ||
|
|
674bc3d614 | ||
|
|
e4a07f9810 | ||
|
|
4e0f3c3eb9 | ||
|
|
ba20dd3086 | ||
|
|
86d5b0465c | ||
|
|
9d358ca6b2 | ||
|
|
f886601185 | ||
|
|
806364f01d | ||
|
|
d35759ec66 | ||
|
|
ffefe1db38 | ||
|
|
2f9efe22d4 | ||
|
|
bbfb3c63ea | ||
|
|
6800f1ae27 | ||
|
|
402d72c85b | ||
|
|
4f54301467 | ||
|
|
be99438123 | ||
|
|
71164417a0 | ||
|
|
59ca170592 | ||
|
|
b37b7ed060 | ||
|
|
dd68c5097a | ||
|
|
5fbbba32f1 | ||
|
|
b10af38df4 | ||
|
|
77a07013a9 | ||
|
|
ccf825df24 |
1
.github/actions/build-image/Dockerfile
vendored
1
.github/actions/build-image/Dockerfile
vendored
@@ -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"]
|
||||||
1
.github/workflows/nightly.yml
vendored
1
.github/workflows/nightly.yml
vendored
@@ -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
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cup"
|
name = "cup"
|
||||||
version = "3.2.0"
|
version = "3.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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!
|
||||||
@@ -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)
|
||||||
|
|||||||
23
src/check.rs
23
src/check.rs
@@ -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))
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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" )));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
14
web/src/components/Badge.tsx
Normal file
14
web/src/components/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user