mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-08 13:13:49 -05:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d5b0465c | ||
|
|
9d358ca6b2 | ||
|
|
f886601185 | ||
|
|
806364f01d | ||
|
|
d35759ec66 | ||
|
|
ffefe1db38 | ||
|
|
2f9efe22d4 | ||
|
|
bbfb3c63ea | ||
|
|
6800f1ae27 | ||
|
|
402d72c85b | ||
|
|
4f54301467 | ||
|
|
be99438123 | ||
|
|
71164417a0 | ||
|
|
59ca170592 | ||
|
|
b37b7ed060 | ||
|
|
dd68c5097a | ||
|
|
5fbbba32f1 | ||
|
|
b10af38df4 | ||
|
|
77a07013a9 | ||
|
|
ccf825df24 | ||
|
|
e26f941c59 | ||
|
|
c411fc4bad | ||
|
|
e965380133 | ||
|
|
ef849b624f | ||
|
|
8db7e2e12b | ||
|
|
54e1998032 | ||
|
|
9f142ab81c | ||
|
|
ffd4d6267c | ||
|
|
242029db22 | ||
|
|
b6562ef76f | ||
|
|
846b24bf2d | ||
|
|
d7f766f1f5 | ||
|
|
60096792a9 | ||
|
|
a599d4e084 | ||
|
|
a5a1f12899 | ||
|
|
766a20ccac | ||
|
|
fe66ba842a | ||
|
|
c06c20394f | ||
|
|
98dafb8ba4 | ||
|
|
2addfca1b4 | ||
|
|
e3b05923ae | ||
|
|
aa4195f8d6 | ||
|
|
1b94629c79 | ||
|
|
8cd9cce94e | ||
|
|
ddabd8c102 | ||
|
|
0b0028ab6d | ||
|
|
75509550b1 | ||
|
|
9716d1a351 | ||
|
|
d5a2556768 | ||
|
|
e7f1921620 | ||
|
|
7ea6ae6de5 | ||
|
|
d7c2e6436c | ||
|
|
fde61ee07d | ||
|
|
c4de3961a0 | ||
|
|
404c574c2c | ||
|
|
6d4df20f54 | ||
|
|
7b3745d095 | ||
|
|
f9aa516da7 |
13
.github/actions/build-image/Dockerfile
vendored
Normal file
13
.github/actions/build-image/Dockerfile
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM --platform=$BUILDPLATFORM alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
|
||||
COPY binaries/* /
|
||||
RUN mv cup-$TARGETOS-$TARGETARCH cup
|
||||
RUN chmod +x cup
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /cup /cup
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/cup"]
|
||||
52
.github/actions/build-image/action.yml
vendored
Normal file
52
.github/actions/build-image/action.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Build Image
|
||||
inputs:
|
||||
tags:
|
||||
description: "Docker image tags"
|
||||
required: true
|
||||
gh-token:
|
||||
description: "Github token"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/sergi0g/cup
|
||||
tags: ${{ inputs.tags }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: sergi0g
|
||||
password: ${{ inputs.gh-token }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./.github/actions/build-image/Dockerfile
|
||||
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
|
||||
35
.github/workflows/nightly.yml
vendored
35
.github/workflows/nightly.yml
vendored
@@ -62,38 +62,25 @@ jobs:
|
||||
cup-linux-arm64
|
||||
|
||||
build-image:
|
||||
needs: get-tag
|
||||
needs:
|
||||
- get-tag
|
||||
- build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
- uses: ./.github/actions/build-image
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64, linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/sergi0g/cup:${{ needs.get-tag.outputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
${{ needs.get-tag.outputs.tag }}
|
||||
gh-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
nightly-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, get-tag]
|
||||
needs:
|
||||
- get-tag
|
||||
- build-binaries
|
||||
- build-image
|
||||
steps:
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -60,34 +60,19 @@ jobs:
|
||||
cup-linux-arm64
|
||||
|
||||
build-image:
|
||||
needs: get-tag
|
||||
needs:
|
||||
- get-tag
|
||||
- build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
- uses: ./.github/actions/build-image
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64, linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/sergi0g/cup:${{ needs.get-tag.outputs.tag }},ghcr.io/sergi0g/cup:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
${{ needs.get-tag.outputs.tag }}
|
||||
latest
|
||||
gh-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1167
Cargo.lock
generated
1167
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "cup"
|
||||
version = "3.0.0"
|
||||
version = "3.2.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
indicatif = { version = "0.17.8", optional = true }
|
||||
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
|
||||
xitca-web = { version = "0.5.0", optional = true }
|
||||
xitca-web = { version = "0.6.2", optional = true }
|
||||
liquid = { version = "0.26.6", optional = true }
|
||||
bollard = "0.16.1"
|
||||
bollard = "0.18.1"
|
||||
once_cell = "1.19.0"
|
||||
http-auth = { version = "0.1.9", default-features = false }
|
||||
termsize = { version = "0.1.8", optional = true }
|
||||
@@ -17,11 +17,11 @@ regex = { version = "1.10.5", default-features = false, features = ["perf"] }
|
||||
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true }
|
||||
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] }
|
||||
futures = "0.3.30"
|
||||
reqwest-retry = "0.6.1"
|
||||
reqwest-retry = "0.7.0"
|
||||
reqwest-middleware = "0.3.3"
|
||||
rustc-hash = "2.0.0"
|
||||
http-link = "1.0.1"
|
||||
itertools = "0.13.0"
|
||||
itertools = "0.14.0"
|
||||
serde_json = "1.0.133"
|
||||
serde = "1.0.215"
|
||||
tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true }
|
||||
|
||||
@@ -15,7 +15,7 @@ RUN ~/.bun/bin/bun install
|
||||
RUN ~/.bun/bin/bun run build
|
||||
|
||||
### Build Cup ###
|
||||
FROM rust:1.80.1-alpine AS build
|
||||
FROM rust:1-alpine AS build
|
||||
|
||||
# Requirements
|
||||
RUN apk add musl-dev
|
||||
@@ -39,4 +39,5 @@ FROM scratch
|
||||
# Copy binary
|
||||
COPY --from=build /cup/target/release/cup /cup
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/cup"]
|
||||
12
README.md
12
README.md
@@ -1,5 +1,13 @@
|
||||
# Cup 🥤
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/jmh5ctzwNG)
|
||||
|
||||
|
||||
Cup is the easiest way to check for container image updates.
|
||||
|
||||

|
||||
@@ -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.
|
||||
|
||||
|
||||
BIN
docs/bun.lockb
BIN
docs/bun.lockb
Binary file not shown.
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build && pagefind --site out --output-path out/_pagefind",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fmt": "bun prettier --write ."
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.29.0",
|
||||
"geist": "^1.3.1",
|
||||
"next": "15.1.5",
|
||||
"next": "15.2.3",
|
||||
"nextra": "^4.1.0",
|
||||
"nextra-theme-docs": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "15.1.5",
|
||||
"pagefind": "^1.3.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
||||
BIN
docs/src/app/assets/ha-cup-component.png
Normal file
BIN
docs/src/app/assets/ha-cup-component.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -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>
|
||||
|
||||
@@ -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!
|
||||
47
docs/src/content/docs/community-resources/home-assistant.mdx
Normal file
47
docs/src/content/docs/community-resources/home-assistant.mdx
Normal 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
|
||||
|
||||
[](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:
|
||||
[](https://www.buymeacoffee.com/bastgau)
|
||||
@@ -4,7 +4,7 @@ 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 0,30 * * * *" // Check twice an hour
|
||||
// Other options
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
50
src/check.rs
50
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";
|
||||
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()));
|
||||
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status()));
|
||||
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) => {
|
||||
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();
|
||||
}
|
||||
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.
|
||||
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))
|
||||
@@ -120,6 +129,16 @@ pub async fn get_updates(
|
||||
.iter()
|
||||
.map(|image| &image.parts.registry)
|
||||
.unique()
|
||||
.filter(|®istry| match ctx.config.registries.get(registry) {
|
||||
Some(config) => {
|
||||
if config.ignore {
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
})
|
||||
.collect::<Vec<&String>>();
|
||||
|
||||
// Create request client. All network requests share the same client for better performance.
|
||||
@@ -138,7 +157,7 @@ pub async fn get_updates(
|
||||
|
||||
// Retrieve an authentication token (if required) for each registry.
|
||||
let mut tokens: FxHashMap<&str, Option<String>> = FxHashMap::default();
|
||||
for registry in registries {
|
||||
for registry in registries.clone() {
|
||||
let credentials = if let Some(registry_config) = ctx.config.registries.get(registry) {
|
||||
®istry_config.authentication
|
||||
} else {
|
||||
@@ -163,24 +182,11 @@ pub async fn get_updates(
|
||||
|
||||
ctx.logger.debug(format!("Tokens: {:?}", tokens));
|
||||
|
||||
let ignored_registries = ctx
|
||||
.config
|
||||
.registries
|
||||
.iter()
|
||||
.filter_map(|(registry, registry_config)| {
|
||||
if registry_config.ignore {
|
||||
Some(registry)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<&String>>();
|
||||
|
||||
let mut handles = Vec::with_capacity(images.len());
|
||||
|
||||
// Loop through images check for updates
|
||||
for image in &images {
|
||||
let is_ignored = ignored_registries.contains(&&image.parts.registry)
|
||||
let is_ignored = !registries.contains(&&image.parts.registry)
|
||||
|| ctx
|
||||
.config
|
||||
.images
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
src/http.rs
12
src/http.rs
@@ -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 {
|
||||
@@ -95,6 +95,10 @@ impl Client {
|
||||
let message = format!("{} {}: Connection timed out!", method, url);
|
||||
self.ctx.logger.warn(&message);
|
||||
Err(message)
|
||||
} else if error.is_middleware() {
|
||||
let message = format!("{} {}: Connection failed after 3 retries!", method, url);
|
||||
self.ctx.logger.warn(&message);
|
||||
Err(message)
|
||||
} else {
|
||||
error!(
|
||||
"{} {}: Unexpected error: {}",
|
||||
@@ -110,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)
|
||||
@@ -120,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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -205,18 +205,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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,7 +16,12 @@ pub fn split(reference: &str) -> (String, String, String) {
|
||||
}
|
||||
}
|
||||
};
|
||||
let splits = repository_and_tag.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 +43,9 @@ pub fn split(reference: &str) -> (String, String, String) {
|
||||
};
|
||||
(repository, tag)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
_ => {
|
||||
panic!("Failed to parse reference! Splits: {:?}", splits)
|
||||
}
|
||||
};
|
||||
(registry.to_string(), repository, tag.to_string())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
nodejs 22.8.0
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Image {
|
||||
repository: string;
|
||||
tag: string;
|
||||
};
|
||||
url: string | null;
|
||||
result: {
|
||||
has_update: boolean | null;
|
||||
info: VersionInfo | DigestInfo | null;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user