├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── build-image │ │ ├── Dockerfile │ │ └── action.yml └── workflows │ ├── ci.yml │ ├── docs.yml │ ├── nightly.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── cup.schema.json ├── docs ├── .prettierignore ├── .prettierrc ├── README.md ├── bun.lock ├── eslint.config.mjs ├── next-env.d.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public │ ├── cup-og.png │ └── favicon.svg ├── src │ ├── app │ │ ├── [...mdxPath] │ │ │ └── page.tsx │ │ ├── apple-icon.png │ │ ├── assets │ │ │ ├── 350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png │ │ │ ├── 358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg │ │ │ ├── blue_theme.png │ │ │ ├── cup.gif │ │ │ ├── ha-cup-component.png │ │ │ ├── hero-dark.png │ │ │ ├── hero-mobile-dark.png │ │ │ ├── hero-mobile.png │ │ │ └── hero.png │ │ ├── components │ │ │ ├── Browser.tsx │ │ │ ├── Card.tsx │ │ │ ├── GradientText.tsx │ │ │ ├── GridPattern.tsx │ │ │ ├── Head.tsx │ │ │ ├── Logo.tsx │ │ │ └── pages │ │ │ │ ├── home.tsx │ │ │ │ └── styles.css │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── content │ │ ├── _meta.ts │ │ └── docs │ │ │ ├── _meta.ts │ │ │ ├── community-resources │ │ │ ├── docker-compose.mdx │ │ │ ├── home-assistant.mdx │ │ │ └── homepage-widget.mdx │ │ │ ├── configuration │ │ │ ├── agent.mdx │ │ │ ├── authentication.mdx │ │ │ ├── automatic-refresh.mdx │ │ │ ├── ignore-registry.mdx │ │ │ ├── ignore-update-type.mdx │ │ │ ├── include-exclude-images.mdx │ │ │ ├── index.mdx │ │ │ ├── insecure-registries.mdx │ │ │ ├── servers.mdx │ │ │ ├── socket.mdx │ │ │ └── theme.mdx │ │ │ ├── contributing.mdx │ │ │ ├── index.mdx │ │ │ ├── installation │ │ │ ├── _meta.ts │ │ │ ├── binary.mdx │ │ │ └── docker.mdx │ │ │ ├── integrations.mdx │ │ │ ├── nightly.mdx │ │ │ └── usage │ │ │ ├── cli.mdx │ │ │ ├── index.mdx │ │ │ └── server.mdx │ └── mdx-components.ts └── tsconfig.json ├── screenshots ├── cup.gif ├── web_dark.png └── web_light.png ├── src ├── check.rs ├── config.rs ├── docker.rs ├── formatting │ ├── mod.rs │ └── spinner.rs ├── http.rs ├── logging.rs ├── main.rs ├── registry.rs ├── server.rs ├── structs │ ├── image.rs │ ├── inspectdata.rs │ ├── mod.rs │ ├── parts.rs │ ├── status.rs │ ├── update.rs │ └── version.rs └── utils │ ├── json.rs │ ├── link.rs │ ├── mod.rs │ ├── reference.rs │ ├── request.rs │ ├── sort_update_vec.rs │ └── time.rs └── web ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── bun.lock ├── eslint.config.js ├── index.html ├── index.liquid ├── package.json ├── postcss.config.js ├── public ├── apple-touch-icon.png ├── favicon.ico └── favicon.svg ├── src ├── App.tsx ├── components │ ├── Badge.tsx │ ├── CodeBlock.tsx │ ├── DataLoadingError.tsx │ ├── Filters.tsx │ ├── Image.tsx │ ├── LastChecked.tsx │ ├── Loading.tsx │ ├── Logo.tsx │ ├── RefreshButton.tsx │ ├── Search.tsx │ ├── Server.tsx │ ├── Statistic.tsx │ └── ui │ │ ├── Checkbox.tsx │ │ ├── Select.tsx │ │ └── Tooltip.tsx ├── hooks │ └── use-data.tsx ├── index.css ├── main.tsx ├── theme.ts ├── types.ts ├── utils.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Is something not working properly? Report it here. 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System info (please complete the following information):** 27 | - OS: [e.g. Ubuntu] 28 | - Docker daemon version: [Engine version in the output of `docker version`] 29 | - Cup version: [Output of `cup --version`] 30 | 31 | **Additional context** 32 | Add any other info that you think may be useful here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FR] <TITLE>" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/build-image/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM alpine AS builder 2 | 3 | ARG TARGETARCH 4 | ARG TARGETOS 5 | 6 | COPY binaries/* / 7 | RUN mv cup-$TARGETOS-$TARGETARCH cup 8 | RUN chmod +x cup 9 | 10 | FROM scratch 11 | COPY --from=builder /cup /cup 12 | EXPOSE 8000 13 | ENTRYPOINT ["/cup"] -------------------------------------------------------------------------------- /.github/actions/build-image/action.yml: -------------------------------------------------------------------------------- 1 | name: Build Image 2 | inputs: 3 | tags: 4 | description: "Docker image tags" 5 | required: true 6 | gh-token: 7 | description: "Github token" 8 | required: true 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Download binaries 17 | uses: actions/download-artifact@v4 18 | with: 19 | path: . 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Docker meta 28 | id: meta 29 | uses: docker/metadata-action@v5 30 | with: 31 | images: | 32 | ghcr.io/sergi0g/cup 33 | tags: ${{ inputs.tags }} 34 | 35 | - name: Login to GitHub Container Registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: sergi0g 40 | password: ${{ inputs.gh-token }} 41 | 42 | - name: Build and push image 43 | uses: docker/build-push-action@v6 44 | with: 45 | context: . 46 | file: ./.github/actions/build-image/Dockerfile 47 | platforms: linux/amd64,linux/arm64 48 | push: true 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | cache-from: type=gha 52 | cache-to: type=gha,mode=max 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: "main" 6 | 7 | jobs: 8 | build-binary: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Rust 15 | uses: actions-rust-lang/setup-rust-toolchain@v1 16 | 17 | - name: Set up Bun 18 | uses: oven-sh/setup-bun@v2 19 | 20 | - name: Install deps 21 | run: cd web && bun install 22 | 23 | - name: Build 24 | run: ./build.sh cargo build --verbose 25 | 26 | - name: Test 27 | run: cargo test 28 | 29 | build-image: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Build and push image 39 | uses: docker/build-push-action@v6 40 | with: 41 | context: . 42 | platforms: linux/amd64 43 | push: false 44 | cache-from: type=gha 45 | cache-to: type=gha,mode=max 46 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy github pages 2 | on: 3 | push: 4 | paths: 5 | - 'docs/**' 6 | workflow_dispatch: 7 | jobs: 8 | build: 9 | defaults: 10 | run: 11 | working-directory: docs 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Bun 19 | uses: oven-sh/setup-bun@v2 20 | - name: Install dependencies 21 | run: bun install 22 | - name: Build 23 | run: bun run build 24 | - name: Upload artifact 25 | uses: actions/upload-pages-artifact@v3 26 | with: 27 | path: docs/out/ 28 | deploy: 29 | if: ${{ github.ref == 'refs/heads/main' }} 30 | needs: build 31 | permissions: 32 | pages: write 33 | id-token: write 34 | environment: 35 | name: github-pages 36 | url: ${{ steps.deployment.outputs.page_url }} 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Deploy to GitHub Pages 40 | id: deployment 41 | uses: actions/deploy-pages@v4 42 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Release 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | get-tag: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | tag: ${{ steps.tag.outputs.tag }} 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Get Docker image tag 14 | id: tag 15 | run: | 16 | if [ "${GITHUB_REF_NAME}" == "main" ]; then 17 | TAG="nightly" 18 | else 19 | TAG="${GITHUB_REF_NAME}-nightly" 20 | fi 21 | echo "Using tag $TAG" 22 | echo "tag=$TAG" >> $GITHUB_OUTPUT 23 | build-binaries: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Rust 30 | uses: actions-rust-lang/setup-rust-toolchain@v1 31 | 32 | - name: Install cross 33 | run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross 34 | 35 | - name: Set up Bun 36 | uses: oven-sh/setup-bun@v2 37 | 38 | - name: Install deps 39 | run: cd web && bun install 40 | 41 | - name: Build amd64 binary 42 | run: | 43 | ./build.sh cross build --target x86_64-unknown-linux-musl --release 44 | mv target/x86_64-unknown-linux-musl/release/cup ./cup-linux-amd64 45 | 46 | - name: Build arm64 binary 47 | run: | 48 | ./build.sh cross build --target aarch64-unknown-linux-musl --release 49 | mv target/aarch64-unknown-linux-musl/release/cup ./cup-linux-arm64 50 | 51 | - name: Upload binaries 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: binaries 55 | path: | 56 | cup-linux-amd64 57 | cup-linux-arm64 58 | 59 | build-image: 60 | needs: 61 | - get-tag 62 | - build-binaries 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v4 67 | - uses: ./.github/actions/build-image 68 | with: 69 | tags: | 70 | ${{ needs.get-tag.outputs.tag }} 71 | gh-token: ${{ secrets.GITHUB_TOKEN }} 72 | 73 | nightly-release: 74 | runs-on: ubuntu-latest 75 | needs: 76 | - get-tag 77 | - build-binaries 78 | - build-image 79 | steps: 80 | - name: Download binaries 81 | uses: actions/download-artifact@v4 82 | with: 83 | name: binaries 84 | path: binaries 85 | 86 | - uses: pyTooling/Actions/releaser@r0 87 | with: 88 | token: ${{ secrets.GITHUB_TOKEN }} 89 | tag: ${{ needs.get-tag.outputs.tag }} 90 | rm: true 91 | files: binaries/* 92 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | get-tag: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | tag: ${{ steps.tag.outputs.tag }} 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Get current tag 15 | id: tag 16 | run: | 17 | TAG=v$(head -n 4 Cargo.toml | grep version | awk '{print $3}' | tr -d '"') 18 | echo "Current tag: $TAG" 19 | echo "tag=$TAG" >> $GITHUB_OUTPUT 20 | 21 | build-binaries: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Rust 28 | uses: actions-rust-lang/setup-rust-toolchain@v1 29 | 30 | - name: Install cross 31 | run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross 32 | 33 | - name: Set up Bun 34 | uses: oven-sh/setup-bun@v2 35 | 36 | - name: Install deps 37 | run: cd web && bun install 38 | 39 | - name: Build amd64 binary 40 | run: | 41 | ./build.sh cross build --target x86_64-unknown-linux-musl --release 42 | mv target/x86_64-unknown-linux-musl/release/cup ./cup-linux-amd64 43 | 44 | - name: Build arm64 binary 45 | run: | 46 | ./build.sh cross build --target aarch64-unknown-linux-musl --release 47 | mv target/aarch64-unknown-linux-musl/release/cup ./cup-linux-arm64 48 | 49 | - name: Upload binaries 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: binaries 53 | path: | 54 | cup-linux-amd64 55 | cup-linux-arm64 56 | 57 | build-image: 58 | needs: 59 | - get-tag 60 | - build-binaries 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Checkout 64 | uses: actions/checkout@v4 65 | - uses: ./.github/actions/build-image 66 | with: 67 | tags: | 68 | ${{ needs.get-tag.outputs.tag }} 69 | latest 70 | gh-token: ${{ secrets.GITHUB_TOKEN }} 71 | 72 | release: 73 | runs-on: ubuntu-latest 74 | needs: [get-tag, build-image, build-binaries] 75 | steps: 76 | - name: Download binaries 77 | uses: actions/download-artifact@v4 78 | with: 79 | name: binaries 80 | path: binaries 81 | 82 | - name: Create release 83 | uses: softprops/action-gh-release@v2 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | prerelease: true 88 | tag_name: ${{ needs.get-tag.outputs.tag }} 89 | name: ${{ needs.get-tag.outputs.tag }} 90 | files: binaries/* 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /docs/.next 3 | /docs/node_modules 4 | /docs/out 5 | /src/static 6 | 7 | # In case I accidentally commit mine... 8 | cup.json 9 | 10 | # Profiling results don't need to be present in the repo 11 | profile.json -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thanks for taking time to contribute to Cup! This guide will help you set up a development environment and make your first contribution. 4 | 5 | ## Setting up a development environment 6 | 7 | Requirements: 8 | - A computer running Linux 9 | - Rust (usually installed from https://rustup.rs/) 10 | - Bun 1+ 11 | 12 | 1. Fork the repository. This is where you'll be pushing your changes before you create a pull request. Make sure to _create a new branch_ for your changes. 13 | 2. Clone your fork with `git clone https://github.com/<YOUR_USERNAME>/cup` (if you use SSH, `git clone git@github.com:<YOUR_USERNAME>/cup`) and open your editor 14 | 3. Switch to your newly created branch (e.g. if your branch is called `improve-logging`, run `git checkout improve-logging`) 15 | 4. Run `bun install` in `web/` and `./build.sh` to set up the frontend 16 | 17 | You're ready to go! 18 | 19 | ## Project architecture 20 | 21 | Cup can be run in 2 modes: CLI and server. 22 | 23 | All CLI specific functionality is located in `src/formatting.rs` and some other files in functions prefixed with `#[cfg(feature = "cli")]`. 24 | 25 | All server specific functionality is located in `src/server.rs` and `web/`. 26 | 27 | ## Important notes 28 | 29 | - When making any changes, always make sure to write optimize your code for: 30 | + Performance: You should always benchmark Cup before making changes and after your changes to make sure there is none (or a very small) difference in time. Profiling old and new code is also good. 31 | + Readability: Include comments describing any new functions you create, give descriptive names to variables and when making a design decision or a compromise, ALWAYS include a comment explaining what you did and why. 32 | 33 | - If you plan on developing the frontend without making backend changes, it is highly recommended to run `cup serve` in the background and start the frontend in development mode from `web/` with `bun dev`. 34 | 35 | - If you make changes to the frontend, always remember to prefix your build command with the `build.sh` script which takes care of rebuilding the frontend. For example: `./build.sh cargo build -r` 36 | 37 | - When adding new features to Cup (e.g. configuration options), make sure to update the documentation (located in `docs/`). Refer to other pages in the documentation, or to the [official docs](https://nextra.site) for any questions you may have. The docs use `pnpm` as their package manager. 38 | 39 | - If you need help with finishing something (e.g. you've made some commits and need help with writing docs, you want some feedback about a design decision, etc.), you can open a draft PR and ask for help there. 40 | 41 | ## Submitting a PR 42 | 43 | To have your changes included in Cup, you will need to create a pull request. 44 | 45 | Before doing so, please make sure you have run `cargo clippy` and resolved all warnings related to your changes and have formatted your code with `cargo fmt`. This ensures Cup's codebase is consistent and uses good practices for code. 46 | 47 | After you're done with that, commit your changes and push them to your branch. 48 | 49 | Next, open your fork on Github and create a pull request. Make sure to include the changes you made, which issues it addresses (if any) and any other info you think is important. 50 | 51 | Happy contributing! 52 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cup" 3 | version = "3.4.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | clap = { version = "4.5.7", features = ["derive"] } 8 | indicatif = { version = "0.17.8", optional = true } 9 | tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } 10 | xitca-web = { version = "0.6.2", optional = true } 11 | liquid = { version = "0.26.6", optional = true } 12 | bollard = "0.18.1" 13 | once_cell = "1.19.0" 14 | http-auth = { version = "0.1.9", default-features = false } 15 | termsize = { version = "0.1.8", optional = true } 16 | regex = { version = "1.10.5", default-features = false, features = ["perf"] } 17 | chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true } 18 | reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] } 19 | futures = "0.3.30" 20 | reqwest-retry = "0.7.0" 21 | reqwest-middleware = "0.3.3" 22 | rustc-hash = "2.0.0" 23 | http-link = "1.0.1" 24 | itertools = "0.14.0" 25 | serde_json = "1.0.133" 26 | serde = "1.0.215" 27 | tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true } 28 | envy = "0.4.2" 29 | chrono-tz = "0.10.3" 30 | 31 | [features] 32 | default = ["server", "cli"] 33 | server = ["dep:xitca-web", "dep:liquid", "dep:chrono", "dep:tokio-cron-scheduler"] 34 | cli = ["dep:indicatif", "dep:termsize"] 35 | 36 | [profile.release] 37 | opt-level = "z" 38 | strip = "symbols" 39 | panic = "abort" 40 | lto = "fat" 41 | codegen-units = 1 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Build UI ### 2 | FROM oven/bun:1-alpine AS web 3 | 4 | # Copy package.json and lockfile from web 5 | WORKDIR /web 6 | COPY ./web/package.json ./web/bun.lock ./ 7 | 8 | # Install requirements 9 | RUN bun install 10 | 11 | # Copy web folder 12 | COPY ./web . 13 | 14 | # Build frontend 15 | RUN bun run build 16 | 17 | ### Build Cup ### 18 | FROM rust:1-alpine AS build 19 | 20 | # Requirements 21 | RUN apk add musl-dev 22 | 23 | # Copy files 24 | WORKDIR /cup 25 | 26 | COPY Cargo.toml . 27 | COPY Cargo.lock . 28 | COPY ./src ./src 29 | 30 | # Copy UI from web builder 31 | COPY --from=web /web/dist src/static 32 | 33 | # Build 34 | RUN cargo build --release 35 | 36 | ### Main ### 37 | FROM scratch 38 | 39 | # Copy binary 40 | COPY --from=build /cup/target/release/cup /cup 41 | 42 | EXPOSE 8000 43 | ENTRYPOINT ["/cup"] 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cup 🥤 2 | 3 | ![GitHub License](https://img.shields.io/github/license/sergi0g/cup) 4 | ![CI Status](https://img.shields.io/github/actions/workflow/status/sergi0g/cup/.github%2Fworkflows%2Fci.yml?label=CI) 5 | ![GitHub last commit](https://img.shields.io/github/last-commit/sergi0g/cup) 6 | ![GitHub Release](https://img.shields.io/github/v/release/sergi0g/cup) 7 | ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/sergi0g/cup) 8 | [![Discord](https://img.shields.io/discord/1337705080518086658)](https://discord.gg/jmh5ctzwNG) 9 | 10 | 11 | Cup is the easiest way to check for container image updates. 12 | 13 | ![Cup web in dark mode](screenshots/web_dark.png) 14 | 15 | _If you like this project and/or use Cup, please consider starring the project ⭐. It motivates me to continue working on it and improving it. Plus, you get updates for new releases!_ 16 | 17 | ## Screenshots 📷 18 | 19 | ![Cup web in light mode](screenshots/web_light.png) 20 | ![Cup's CLI](screenshots/cup.gif) 21 | 22 | ## Features ✨ 23 | 24 | - 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! 25 | - Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives) 26 | - 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/). 27 | - Beautiful CLI and web interface for checking on your containers any time. 28 | - 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. 29 | - 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! 30 | 31 | ## Documentation 📘 32 | 33 | Take a look at https://cup.sergi0g.dev/docs! 34 | 35 | ## Limitations 36 | 37 | Cup is a work in progress. It might not have as many features as other alternatives. If one of these features is really important for you, please consider using another tool. 38 | 39 | - Cup cannot directly trigger your integrations. If you want that to happen automatically, please use What's up Docker instead. Cup was created to be simple. The data is there, and it's up to you to retrieve it (e.g. by running `cup check -r` with a cronjob or periodically requesting the `/api/v3/json` url from the server). 40 | 41 | ## Roadmap 42 | Take a sneak peek at what's coming up in future releases on the [roadmap](https://github.com/users/sergi0g/projects/2)! 43 | 44 | ## Contributing 45 | 46 | All contributions are welcome! 47 | 48 | Here are some ideas to get you started: 49 | 50 | - Fix a bug from the [issues](https://github.com/sergi0g/cup/issues) 51 | - Help improve the documentation 52 | - Help optimize Cup and make it even better! 53 | - Add more features to the web UI 54 | 55 | For more information, check the [docs](https://cup.sergi0g.dev/docs/contributing)! 56 | 57 | ## Support 58 | 59 | 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). 60 | 61 | 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. 62 | 63 | ## Acknowledgements 64 | 65 | Thanks to [What's up Docker?](https://github.com/getwud/wud) for inspiring this project. 66 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit on error 4 | set -e 5 | 6 | # This is kind of like a shim that makes sure the frontend is rebuilt when running a build. For example you can run `./build.sh cargo build --release` 7 | 8 | # Remove old files 9 | rm -rf src/static 10 | 11 | # Frontend 12 | cd web/ 13 | 14 | # Build 15 | bun run build 16 | 17 | # Copy UI to src folder 18 | cp -r dist/ ../src/static 19 | 20 | # Go back 21 | cd ../ 22 | 23 | # Run command from argv 24 | 25 | $@ -------------------------------------------------------------------------------- /cup.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json", 4 | "title": "Cup", 5 | "description": "A schema for Cup's config file", 6 | "type": "object", 7 | "properties": { 8 | "version": { 9 | "type": "integer", 10 | "minimum": 3, 11 | "maximum": 3 12 | }, 13 | "agent": { 14 | "type": "boolean", 15 | "description": "Whether or not to enable agent mode. When agent mode is enabled, the server only exposes the API and the web interface is unavailable." 16 | }, 17 | "ignore_update_type": { 18 | "type": "string", 19 | "description": "The types of updates to ignore. Ignoring an update type also implies ignoring all update types less specific than it. For example, ignoring patch updates also implies ignoring major and minor updates.", 20 | "enum": [ 21 | "none", 22 | "major", 23 | "minor", 24 | "patch" 25 | ] 26 | }, 27 | "images": { 28 | "type": "object", 29 | "description": "Configuration options for specific images", 30 | "properties": { 31 | "extra": { 32 | "type": "array", 33 | "description": "Extra image references you want Cup to check", 34 | "minItems": 1 35 | }, 36 | "exclude": { 37 | "type": "array", 38 | "description": "Image references that should be excluded from the check", 39 | "minItems": 1, 40 | "items": { 41 | "type": "string", 42 | "minLength": 1 43 | } 44 | } 45 | } 46 | }, 47 | "refresh_interval": { 48 | "type": "string", 49 | "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", 50 | "minLength": 11 51 | }, 52 | "registries": { 53 | "type": "object", 54 | "description": "Configuration options for specific registries", 55 | "additionalProperties": { 56 | "authentication": { 57 | "description": "An authentication token provided by the registry", 58 | "type": "string", 59 | "minLength": 1 60 | }, 61 | "insecure": { 62 | "description": "Whether Cup should connect to the registry insecurely (HTTP) or not. Enable this only if you really need to.", 63 | "type": "boolean" 64 | }, 65 | "ignore": { 66 | "description": "Whether or not the registry should be ignored when running Cup", 67 | "type": "boolean" 68 | } 69 | } 70 | }, 71 | "socket": { 72 | "type": "string", 73 | "description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman.", 74 | "minLength": 1 75 | }, 76 | "servers": { 77 | "type": "object", 78 | "description": "Additional servers to connect to and fetch update data from", 79 | "additionalProperties": { 80 | "type": "string", 81 | "minLength": 1 82 | }, 83 | "minProperties": 1 84 | }, 85 | "theme": { 86 | "type": "string", 87 | "description": "The theme used by the web UI", 88 | "enum": [ 89 | "default", 90 | "blue" 91 | ] 92 | } 93 | }, 94 | "required": [ 95 | "version" 96 | ] 97 | } -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | .node_modules -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "src/content/docs/integrations.mdx", 5 | "options": { 6 | "tabWidth": 4 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Cup Documentation 2 | 3 | ## Architecture 4 | 5 | The docs are built with [Nextra](https://nextra.site). We use [Bun](https://bun.sh) as a package manager and Node.js as a runtime (Next.js and Bun don't play well together at the moment). Docs pages are written in [MDX](https://mdxjs.com) and any custom components are written in TypeScript with TSX. 6 | 7 | ## Development 8 | 9 | Prerequisites: 10 | 11 | - A recent Node.js version (22 recommended) 12 | - [Bun](https://bun.sh) 13 | 14 | ```bash 15 | git clone https://github.com/sergi0g/cup 16 | cd cup/docs 17 | bun install 18 | ``` 19 | 20 | You're ready to go! 21 | 22 | ## Scripts 23 | 24 | The available scripts are: 25 | 26 | - `bun dev` starts the development server. Note that making changes to MDX pages will probably require a full reload. 27 | - `bun run build` creates a static production build, ready to be deployed. 28 | - `bun lint` checks for errors in your code. 29 | - `bun fmt` formats your code with Prettier, so it becomes... prettier. 30 | 31 | ## Contributing 32 | 33 | Our documentation is always evolving, so, we constantly need to update this repository with new guides and configuration options. If you have any ideas of a guide or suggestions on how to improve them, feel free to open a pull request or create an issue. All contributions are welcome! 34 | 35 | ## License 36 | 37 | The documentation is licensed under the MIT License. TL;DR — You are free to use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software. However, the software is provided "as is," without warranty of any kind. You must include the original license in all copies or substantial portions of the software. 38 | -------------------------------------------------------------------------------- /docs/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "import/no-anonymous-default-export": "off", 17 | }, 18 | }, 19 | ]; 20 | 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="next" /> 2 | /// <reference types="next/image-types/global" /> 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/next.config.ts: -------------------------------------------------------------------------------- 1 | import nextra from "nextra"; 2 | 3 | const withNextra = nextra({ 4 | defaultShowCopyCode: true, 5 | }); 6 | 7 | export default withNextra({ 8 | output: "export", 9 | transpilePackages: ["geist"], 10 | images: { 11 | unoptimized: true, 12 | remotePatterns: [ 13 | { 14 | protocol: "https", 15 | hostname: "raw.githubusercontent.com", 16 | }, 17 | ], 18 | }, 19 | basePath: "", 20 | }); 21 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cup-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build && pagefind --site out --output-path out/_pagefind", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "fmt": "bun prettier --write ." 11 | }, 12 | "dependencies": { 13 | "@tabler/icons-react": "^3.29.0", 14 | "geist": "^1.3.1", 15 | "next": "15.2.4", 16 | "nextra": "^4.1.0", 17 | "nextra-theme-docs": "^4.1.0", 18 | "react": "^19.0.0", 19 | "react-dom": "^19.0.0" 20 | }, 21 | "devDependencies": { 22 | "@eslint/eslintrc": "^3.2.0", 23 | "@tailwindcss/postcss": "^4.0.1", 24 | "@types/bun": "^1.2.10", 25 | "@types/react": "^19.0.7", 26 | "@types/react-dom": "^19.0.3", 27 | "eslint": "^9.18.0", 28 | "eslint-config-next": "15.1.5", 29 | "pagefind": "^1.3.0", 30 | "postcss": "^8.5.1", 31 | "prettier": "^3.4.2", 32 | "prettier-plugin-tailwindcss": "^0.6.11", 33 | "tailwindcss": "^4.0.1", 34 | "typescript": "^5.7.3" 35 | } 36 | } -------------------------------------------------------------------------------- /docs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | "@tailwindcss/postcss": {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /docs/public/cup-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/public/cup-og.png -------------------------------------------------------------------------------- /docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> 3 | <svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" 4 | viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve"> 5 | <path style="fill:#A6CFD6;" d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95 6 | c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06 7 | C97.82,30.66,94.2,18.43,65.12,17.55z"/> 8 | <path style="fill:#DCEDF6;" d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16 9 | c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"/> 10 | <path style="fill:#6CA4AE;" d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7 11 | S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"/> 12 | <path style="fill:#DC0D27;" d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06 13 | s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73 14 | C110.47,3.47,86.08,11.74,84.85,13.1z"/> 15 | <path style="fill:#8A1F0F;" d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47 16 | c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"/> 17 | <g> 18 | <path style="fill:#8A1F0F;" d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97 19 | c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"/> 20 | </g> 21 | <g> 22 | <path style="fill:#8A1F0F;" d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99 23 | c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"/> 24 | </g> 25 | <g> 26 | <path style="fill:#8A1F0F;" d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96 27 | c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"/> 28 | </g> 29 | </svg> 30 | -------------------------------------------------------------------------------- /docs/src/app/[...mdxPath]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateStaticParamsFor, importPage } from "nextra/pages"; 2 | import { useMDXComponents } from "@/mdx-components"; 3 | 4 | export const generateStaticParams = generateStaticParamsFor("mdxPath"); 5 | 6 | interface Props { 7 | params: Promise<{ mdxPath: string[] }>; 8 | } 9 | 10 | export async function generateMetadata(props: Props) { 11 | const params = await props.params; 12 | const { metadata } = await importPage(params.mdxPath); 13 | return metadata; 14 | } 15 | /* eslint-disable-next-line */ 16 | const Wrapper = useMDXComponents({}).wrapper; 17 | 18 | export default async function Page(props: Props) { 19 | const params = await props.params; 20 | const result = await importPage(params.mdxPath); 21 | const { default: MDXContent, toc, metadata } = result; 22 | return ( 23 | <Wrapper toc={toc} metadata={metadata}> 24 | <MDXContent {...props} params={params} /> 25 | </Wrapper> 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/apple-icon.png -------------------------------------------------------------------------------- /docs/src/app/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png -------------------------------------------------------------------------------- /docs/src/app/assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg -------------------------------------------------------------------------------- /docs/src/app/assets/blue_theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/blue_theme.png -------------------------------------------------------------------------------- /docs/src/app/assets/cup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/cup.gif -------------------------------------------------------------------------------- /docs/src/app/assets/ha-cup-component.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/ha-cup-component.png -------------------------------------------------------------------------------- /docs/src/app/assets/hero-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/hero-dark.png -------------------------------------------------------------------------------- /docs/src/app/assets/hero-mobile-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/hero-mobile-dark.png -------------------------------------------------------------------------------- /docs/src/app/assets/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/hero-mobile.png -------------------------------------------------------------------------------- /docs/src/app/assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/assets/hero.png -------------------------------------------------------------------------------- /docs/src/app/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { Icon as IconType } from "@tabler/icons-react"; 2 | 3 | export function Card({ 4 | name, 5 | icon: Icon, 6 | description, 7 | }: { 8 | name: string; 9 | icon: IconType; 10 | description: string; 11 | }) { 12 | return ( 13 | <div className="p-4 bg-white dark:bg-black group"> 14 | <Icon className="text-black size-7 group-hover:size-9 dark:text-white inline mr-2 transition-[width,height] duration-200" /> 15 | <span className="align-middle text-2xl font-bold text-black dark:text-white"> 16 | {name} 17 | </span> 18 | <p className="text-xl font-semibold text-neutral-500 dark:text-neutral-500"> 19 | {description} 20 | </p> 21 | </div> 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/app/components/GradientText.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { clsx } from "clsx"; 3 | 4 | export function GradientText({ 5 | text, 6 | innerClassName, 7 | className, 8 | blur, 9 | }: { 10 | text: string; 11 | innerClassName: string; 12 | className?: string; 13 | blur: number; 14 | }) { 15 | return ( 16 | <div className={clsx("relative", className)}> 17 | <p 18 | className={clsx("bg-clip-text text-transparent w-fit", innerClassName)} 19 | > 20 | {text} 21 | </p> 22 | <p 23 | className={clsx( 24 | "pointer-events-none absolute top-0 hidden select-none bg-clip-text text-transparent dark:block", 25 | innerClassName, 26 | )} 27 | style={{ filter: `blur(${blur}px)` }} 28 | > 29 | {text} 30 | </p> 31 | </div> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /docs/src/app/components/GridPattern.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | 3 | const SIZE = 36; 4 | 5 | export function GridPattern() { 6 | const id = useId(); 7 | 8 | return ( 9 | <svg 10 | aria-hidden="true" 11 | className="pointer-events-none absolute inset-0 bottom-0 left-0 right-0 top-0 h-full w-full -z-10 bg-white stroke-neutral-200 dark:stroke-white/10 dark:bg-black" 12 | > 13 | <defs> 14 | <pattern 15 | id={id} 16 | width={SIZE} 17 | height={SIZE} 18 | patternUnits="userSpaceOnUse" 19 | x={-1} 20 | y={-1} 21 | > 22 | <path 23 | d={`M.5 ${SIZE}V.5H${SIZE}`} 24 | fill="none" 25 | /> 26 | </pattern> 27 | </defs> 28 | <rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} /> 29 | </svg> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/src/app/components/Head.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Head as NextraHead } from "nextra/components"; 4 | 5 | export function Head() { 6 | return ( 7 | <NextraHead> 8 | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 9 | <link rel="icon" type="image/x-icon" href="/favicon.ico" /> 10 | <meta 11 | name="theme-color" 12 | media="(prefers-color-scheme: light)" 13 | content="#ffffff" 14 | /> 15 | <meta 16 | name="theme-color" 17 | media="(prefers-color-scheme: dark)" 18 | content="#111111" 19 | /> 20 | <meta 21 | name="og:image" 22 | content="https://raw.githubusercontent.com/sergi0g/cup/main/docs/public/cup-og.png" 23 | /> 24 | <meta name="twitter:card" content="summary_large_image" /> 25 | <meta name="twitter:site" content="https://cup.sergi0g.dev" /> 26 | <meta name="apple-mobile-web-app-title" content="Cup" /> 27 | </NextraHead> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /docs/src/app/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 | <svg 4 | viewBox="0 0 128 128" 5 | style={{ height: "calc(var(--nextra-navbar-height) * 0.6)" }} 6 | > 7 | <path 8 | style={{ fill: "#A6CFD6" }} 9 | d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95 10 | c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06 11 | C97.82,30.66,94.2,18.43,65.12,17.55z" 12 | /> 13 | <path 14 | style={{ fill: "#DCEDF6" }} 15 | d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16 16 | c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z" 17 | /> 18 | <path 19 | style={{ fill: "#6CA4AE" }} 20 | d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7 21 | S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z" 22 | /> 23 | <path 24 | style={{ fill: "#DC0D27" }} 25 | d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06 26 | s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73 27 | C110.47,3.47,86.08,11.74,84.85,13.1z" 28 | /> 29 | <path 30 | style={{ fill: "#8A1F0F" }} 31 | d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47 32 | c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z" 33 | /> 34 | <g> 35 | <path 36 | style={{ fill: "#8A1F0F" }} 37 | d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97 38 | c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z" 39 | /> 40 | </g> 41 | <g> 42 | <path 43 | style={{ fill: "#8A1F0F" }} 44 | d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99 45 | c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z" 46 | /> 47 | </g> 48 | <g> 49 | <path 50 | style={{ fill: "#8A1F0F" }} 51 | d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96 52 | c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z" 53 | /> 54 | </g> 55 | </svg> 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /docs/src/app/components/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./styles.css"; 3 | 4 | import { Browser } from "../Browser"; 5 | import { Card } from "../Card"; 6 | import { 7 | IconAdjustments, 8 | IconArrowRight, 9 | IconBarrierBlockOff, 10 | IconBolt, 11 | IconFeather, 12 | IconGitMerge, 13 | IconPuzzle, 14 | IconServer, 15 | IconTerminal, 16 | } from "@tabler/icons-react"; 17 | import { GitHubIcon } from "nextra/icons"; 18 | import { GridPattern } from "../GridPattern"; 19 | import { GradientText } from "../GradientText"; 20 | import Link from "next/link"; 21 | 22 | export default async function Home() { 23 | return ( 24 | <> 25 | <div className="relative home bg-radial-[ellipse_at_center] from-transparent from-20% to-white dark:to-black"> 26 | <GridPattern /> 27 | <div className="px-4 pt-16 pb-8 sm:pt-24 lg:px-8"> 28 | <div className="flex w-full flex-col items-center justify-between"> 29 | <div> 30 | <h1 className="mx-auto max-w-2xl text-center text-6xl leading-none font-extrabold tracking-tighter text-black sm:text-7xl dark:text-white"> 31 | The easiest way to manage your 32 | <GradientText 33 | text="container updates." 34 | className="mx-auto w-fit" 35 | innerClassName="bg-linear-to-r/oklch from-blue-500 to-green-500" 36 | blur={30} 37 | /> 38 | </h1> 39 | <h3 className="mx-auto mt-6 max-w-3xl text-center text-xl leading-tight font-medium text-neutral-500 dark:text-neutral-400"> 40 | Cup is a small utility with a big impact. Simplify your 41 | container management workflow with fast and efficient update 42 | checking, a full-featured CLI and web interface, and more. 43 | </h3> 44 | </div> 45 | <div className="mt-8 grid w-fit grid-cols-2 gap-4 *:flex *:items-center *:gap-2 *:rounded-lg *:px-3 *:py-2"> 46 | <Link 47 | href="/docs" 48 | className="hide-focus group h-full bg-black text-white dark:bg-white dark:text-black" 49 | > 50 | Get started 51 | <IconArrowRight className="ml-auto mr-1 transition-transform duration-300 ease-out group-hover:translate-x-1 group-focus:translate-x-1 dark:!text-black" /> 52 | </Link> 53 | <a 54 | href="https://github.com/sergi0g/cup" 55 | target="_blank" 56 | className="hide-focus h-full bg-white dark:bg-black text-nowrap border border-black/15 transition-colors duration-200 ease-in-out hover:border-black/40 dark:border-white/15 hover:dark:border-white/40 hover:dark:shadow-sm focus:dark:border-white/30" 57 | > 58 | Star on GitHub 59 | <GitHubIcon className="ml-auto size-4 md:size-5" /> 60 | </a> 61 | </div> 62 | </div> 63 | </div> 64 | <div className="py-10 flex translate-y-32 justify-center" id="hero"> 65 | <Browser /> 66 | </div> 67 | </div> 68 | <div className="bg-white dark:bg-black py-12 px-8 w-full"> 69 | <div className="flex h-full w-full items-center justify-center"> 70 | <div className="grid md:grid-cols-2 md:grid-rows-4 lg:grid-cols-4 lg:grid-rows-2 w-full max-w-7xl gap-px border border-transparent bg-black/10 dark:bg-white/10"> 71 | <Card 72 | name="Built for speed." 73 | icon={IconBolt} 74 | description="Cup is written in Rust and every release goes through extensive profiling to squeeze out every last drop of performance." 75 | /> 76 | <Card 77 | name="Configurable." 78 | icon={IconAdjustments} 79 | description="Make Cup yours with the extensive configuration options available. Customize and tailor it to your needs." 80 | /> 81 | <Card 82 | name="Extend it." 83 | icon={IconPuzzle} 84 | description="JSON output enables you to connect Cup with your favorite integrations, build automations and more." 85 | /> 86 | <Card 87 | name="CLI available." 88 | icon={IconTerminal} 89 | description="Do you like terminals? Cup has a CLI. Check for updates quickly without spinning up a server." 90 | /> 91 | <Card 92 | name="Multiple servers." 93 | icon={IconServer} 94 | description="Run multiple Cup instances and effortlessly check on them through one web interface." 95 | /> 96 | <Card 97 | name="Unstoppable." 98 | icon={IconBarrierBlockOff} 99 | description="Cup is designed to check for updates without using up any rate limits. 10 images per hour won't be a problem, even with 100 images." 100 | /> 101 | <Card 102 | name="Lightweight." 103 | icon={IconFeather} 104 | description="No need for a powerful server and endless storage. The tiny 5.4 MB binary won't hog your CPU and memory." 105 | /> 106 | <Card 107 | name="Open source." 108 | icon={IconGitMerge} 109 | description="All source code is publicly available in our GitHub repository. We're looking for contributors!" 110 | /> 111 | </div> 112 | </div> 113 | </div> 114 | </> 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /docs/src/app/components/pages/styles.css: -------------------------------------------------------------------------------- 1 | article:has(.home) { 2 | padding-inline: 0; 3 | padding-top: 0; 4 | padding-bottom: 0; 5 | } 6 | 7 | article div.x\:mt-16:last-child:empty { 8 | margin-top: 0; 9 | } 10 | 11 | #hero { 12 | animation-name: hero; 13 | animation-duration: 1500ms; 14 | animation-delay: 500ms; 15 | animation-timing-function: ease-in-out; 16 | animation-fill-mode: forwards; 17 | } 18 | 19 | @keyframes hero { 20 | from { 21 | translate: 0 8rem; 22 | } 23 | to { 24 | translate: 0 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/docs/src/app/favicon.ico -------------------------------------------------------------------------------- /docs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @variant dark (&:where(.dark, .dark *)); 4 | 5 | .nextra-card .tabler-icon:hover { 6 | color: rgb(17 24 39 / var(--tw-text-opacity)); 7 | } 8 | .nextra-card .tabler-icon { 9 | color: rgb(55 65 81 / var(--tw-text-opacity)); 10 | } 11 | .nextra-card .tabler-icon:is(.dark *) { 12 | color: rgb(229 229 229 / var(--tw-text-opacity)); 13 | } 14 | 15 | .nextra-card .tabler-icon:is(.dark *):hover { 16 | color: rgb(250 250 250 / var(--tw-text-opacity)); 17 | } 18 | -------------------------------------------------------------------------------- /docs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Footer, Layout, Navbar, ThemeSwitch } from "nextra-theme-docs"; 3 | import { getPageMap } from "nextra/page-map"; 4 | import { GeistSans } from "geist/font/sans"; 5 | import "nextra-theme-docs/style.css"; 6 | import "./globals.css"; 7 | import { Head } from "./components/Head"; 8 | import Logo from "./components/Logo"; 9 | 10 | export const metadata: Metadata = { 11 | title: "Cup", 12 | description: "The easiest way to manage your container updates", 13 | }; 14 | 15 | const logo = ( 16 | <div className="flex items-center"> 17 | <Logo /> 18 | <h1 className="ml-2 font-bold">Cup</h1> 19 | </div> 20 | ); 21 | 22 | const navbar = ( 23 | <Navbar logo={logo} projectLink="https://github.com/sergi0g/cup" chatLink="https://discord.gg/jmh5ctzwNG"> 24 | <ThemeSwitch lite className="cursor-pointer" /> 25 | </Navbar> 26 | ); 27 | 28 | const footer = <Footer> </Footer>; 29 | 30 | export default async function RootLayout({ 31 | children, 32 | }: Readonly<{ 33 | children: React.ReactNode; 34 | }>) { 35 | return ( 36 | <html 37 | lang="en" 38 | dir="ltr" 39 | suppressHydrationWarning 40 | className={`${GeistSans.className} antialiased`} 41 | > 42 | <Head /> 43 | <body> 44 | <Layout 45 | navbar={navbar} 46 | pageMap={await getPageMap()} 47 | footer={footer} 48 | docsRepositoryBase="https://github.com/sergi0g/cup/blob/main/docs" 49 | > 50 | <div>{children}</div> 51 | </Layout> 52 | </body> 53 | </html> 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /docs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { useMDXComponents } from "@/mdx-components"; 2 | import { Heading, NextraMetadata } from "nextra"; 3 | import Home from "./components/pages/home"; 4 | 5 | /* eslint-disable-next-line */ 6 | const Wrapper = useMDXComponents({}).wrapper; 7 | 8 | const toc: Heading[] = []; 9 | 10 | export const metadata: NextraMetadata = { 11 | title: "Cup - The easiest way to manage your container updates", 12 | description: "Simple, fast, efficient Docker image update checking", 13 | }; 14 | 15 | export default function Page() { 16 | return ( 17 | // @ts-expect-error This component passes all extra props to the underlying component, but that possibility does not exist in the type declarations. A comment there indicates that passing extra props is intended functionality. 18 | <Wrapper toc={toc} metadata={metadata} className={"x:mx-auto x:flex"}> 19 | <Home /> 20 | </Wrapper> 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/content/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: { 3 | theme: { 4 | sidebar: false, 5 | toc: false, 6 | breadcrumb: false, 7 | pagination: false, 8 | timestamp: false, 9 | layout: "full", 10 | }, 11 | display: "hidden", 12 | }, 13 | docs: { 14 | type: "page", 15 | title: "Documentation", 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /docs/src/content/docs/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | installation: {}, 3 | usage: {}, 4 | configuration: {}, 5 | }; 6 | -------------------------------------------------------------------------------- /docs/src/content/docs/community-resources/docker-compose.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Docker Compose 4 | 5 | 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. 6 | 7 | There have been requests for an official Docker Compose file, but I believe you should customize it to your needs. 8 | 9 | Here is an example of what I would use (by [@ioverho](https://github.com/ioverho)): 10 | 11 | ```yaml 12 | services: 13 | cup: 14 | image: ghcr.io/sergi0g/cup:latest 15 | container_name: cup # Optional 16 | restart: unless-stopped 17 | command: -c /config/cup.json serve 18 | ports: 19 | - 8000:8000 20 | volumes: 21 | - /var/run/docker.sock:/var/run/docker.sock 22 | - ./cup.json:/config/cup.json 23 | ``` 24 | 25 | If you don't have a config, you can use this instead: 26 | 27 | ```yaml 28 | services: 29 | cup: 30 | image: ghcr.io/sergi0g/cup:latest 31 | container_name: cup # Optional 32 | restart: unless-stopped 33 | command: serve 34 | ports: 35 | - 8000:8000 36 | volumes: 37 | - /var/run/docker.sock:/var/run/docker.sock 38 | ``` 39 | 40 | Cup can run with a non-root user, but needs to be in a docker group. Assuming user id of 1000 and `docker` group id of 999 you can add this to the `services.cup` key in the docker compose: 41 | ```yaml 42 | user: "1000:999" 43 | ``` 44 | 45 | <Callout> 46 | You can use the command `getent group docker | cut -d: -f3` to find the group id for the docker group. 47 | </Callout> 48 | 49 | 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! 50 | -------------------------------------------------------------------------------- /docs/src/content/docs/community-resources/home-assistant.mdx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import screenshot from "@/app/assets/ha-cup-component.png"; 4 | 5 | # Home Assistant integration 6 | 7 | Many thanks to [@bastgau](https://github.com/bastgau) for creating this integration. 8 | 9 | ## About 10 | 11 | The **HA Cup Component** integration for Home Assistant allows you to retrieve update statistics for Docker containers directly from your Home Assistant interface. 12 | 13 | With this integration, you can easily track the status of your Docker containers and receive notifications when updates are available. 14 | 15 | The following sensors are currently implemented: 16 | 17 | <Image 18 | src={screenshot} 19 | alt="Screenshot of Home Assistant showing a card with update information provided by Cup" 20 | /> 21 | 22 | ## Installation 23 | 24 | ### Via HACS 25 | 26 | 1. Open Home Assistant and go to HACS 27 | 2. Navigate to "Integrations" and click on "Add a custom repository". 28 | 3. Use https://github.com/bastgau/ha-cup-component as the URL 29 | 4. Search for "HA Cup Component" and install it. 30 | 5. Restart Home Assistant. 31 | 32 | ### One-click install 33 | 34 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bastgau&repository=ha-cup-component&category=Integration) 35 | 36 | ### Manual Installation 37 | 38 | 1. Download the integration files from the GitHub repository. 39 | 2. Place the integration folder in the custom_components directory of Home Assistant. 40 | 3. Restart Home Assistant. 41 | 42 | ## Support & Contributions 43 | 44 | 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). 45 | 46 | Support the author: 47 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bastgau) 48 | -------------------------------------------------------------------------------- /docs/src/content/docs/community-resources/homepage-widget.mdx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import widget1 from "@/app/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png"; 3 | import widget2 from "@/app/assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg"; 4 | 5 | # Homepage Widget 6 | 7 | Some users have asked for a homepage widget. 8 | 9 | ## Docker Compose with the widget configured via labels: 10 | 11 | ```yaml 12 | services: 13 | cup: 14 | image: ghcr.io/sergi0g/cup 15 | container_name: cup 16 | command: -c /config/cup.json serve -p 8000 17 | volumes: 18 | - ./config/cup.json:/config/cup.json 19 | - /var/run/docker.sock:/var/run/docker.sock 20 | ports: 21 | - 8000:8000 22 | restart: unless-stopped 23 | labels: 24 | homepage.group: Network 25 | homepage.name: Cup 26 | homepage.icon: /icons/cup-with-straw.png 27 | homepage.href: http://myserver:8000 28 | homepage.ping: http://myserver:8000 29 | homepage.description: Checks for container updates 30 | homepage.widget.type: customapi 31 | homepage.widget.url: http://myserver:8000/api/v3/json 32 | homepage.widget.mappings[0].label: Monitoring 33 | homepage.widget.mappings[0].field.metrics: monitored_images 34 | homepage.widget.mappings[0].format: number 35 | homepage.widget.mappings[1].label: Up to date 36 | homepage.widget.mappings[1].field.metrics: up_to_date 37 | homepage.widget.mappings[1].format: number 38 | homepage.widget.mappings[2].label: Updates 39 | homepage.widget.mappings[2].field.metrics: updates_available 40 | homepage.widget.mappings[2].format: number 41 | ``` 42 | 43 | Preview: 44 | 45 | <Image src={widget1} /> 46 | 47 | Credit: [@agrmohit](https://github.com/agrmohit) 48 | 49 | ## Widget in Homepage's config file format: 50 | 51 | ```yaml 52 | widget: 53 | type: customapi 54 | url: http://<SERVER_IP>:9000/api/v3/json 55 | refreshInterval: 10000 56 | method: GET 57 | mappings: 58 | - field: 59 | metrics: monitored_images 60 | label: Monitored images 61 | format: number 62 | - field: 63 | metrics: up_to_date 64 | label: Up to date 65 | format: number 66 | - field: 67 | metrics: updates_available 68 | label: Available updates 69 | format: number 70 | - field: 71 | metrics: unknown 72 | label: Unknown 73 | format: number 74 | ``` 75 | 76 | Preview: 77 | 78 | <Image src={widget2} /> 79 | Credit: [@remussamoila](https://github.com/remussamoila) 80 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/agent.mdx: -------------------------------------------------------------------------------- 1 | # Agent mode 2 | 3 | If you'd like to have only the server API exposed without the dashboard, you can run Cup in agent mode. 4 | 5 | Modify your config like this: 6 | 7 | ```jsonc 8 | { 9 | "agent": true 10 | // Other options 11 | } 12 | ``` -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/authentication.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Authentication 4 | 5 | Some registries (or specific images) may require you to be authenticated. For those, you can modify `cup.json` like this: 6 | 7 | ```jsonc 8 | { 9 | "registries": { 10 | "<YOUR_REGISTRY_DOMAIN_1>": { 11 | "authentication": "<YOUR_TOKEN_1>" 12 | // Other options 13 | }, 14 | "<YOUR_REGISTRY_DOMAIN_2>" { 15 | "authentication": "<YOUR_TOKEN_2>" 16 | // Other options 17 | }, 18 | // ... 19 | } 20 | // Other options 21 | } 22 | ``` 23 | 24 | You can use any registry, like `ghcr.io`, `quay.io`, `gcr.io`, etc. 25 | 26 | <Callout emoji="⚠️">For Docker Hub, use `registry-1.docker.io`</Callout> 27 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/automatic-refresh.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Automatic refresh 4 | 5 | Cup can automatically refresh the results when running in server mode. Simply add this to your config: 6 | 7 | ```jsonc 8 | { 9 | "refresh_interval": "0 */30 * * * *", // Check twice an hour 10 | // Other options 11 | } 12 | ``` 13 | 14 | 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). 15 | 16 | <Callout> 17 | If you use a schedule with absolute time (e.g. every day at 6 AM), make sure to set the `TZ` environment variable to your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). 18 | </Callout> 19 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/ignore-registry.mdx: -------------------------------------------------------------------------------- 1 | # Ignored registries 2 | 3 | If you want to skip checking images from some registries, you can modify your config like this: 4 | 5 | ```jsonc 6 | { 7 | "registries": { 8 | "<SOME_REGISTRY_DOMAIN_1>": { 9 | "ignore": true 10 | // Other options 11 | }, 12 | "<SOME_REGISTRY_DOMAIN_2>" { 13 | "ignore": false 14 | // Other options 15 | }, 16 | // ... 17 | } 18 | // Other options 19 | } 20 | ``` 21 | 22 | This configuration option is a bit redundant, since you can achieve the same with [this option](/docs/configuration/include-exclude-images). It's recommended to use that. 23 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/ignore-update-type.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Ignored update types 4 | 5 | To ignore certain update types, you can modify your config like this: 6 | 7 | ```jsonc 8 | { 9 | "ignore_update_type": "minor" 10 | } 11 | ``` 12 | 13 | Available options are: 14 | 15 | - `none`: Do not ignore any update types (default). 16 | - `major`: Ignore major updates. 17 | - `minor`: Ignore major and minor updates. 18 | - `patch`: Ignore major, minor and patch updates. 19 | 20 | <Callout emoji="⚠️"> 21 | Ignoring an update type also implies ignoring all update types less specific than it. 22 | For example, ignoring patch updates also implies ignoring major and minor updates. 23 | </Callout> 24 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/include-exclude-images.mdx: -------------------------------------------------------------------------------- 1 | # Include/Exclude images 2 | 3 | If you want to exclude some images (e.g. because they have too many tags and take too long to check), you can add the following to your config: 4 | 5 | ```jsonc 6 | { 7 | "images": { 8 | "exclude": [ 9 | "ghcr.io/immich-app/immich-machine-learning", 10 | "postgres:15" 11 | ] 12 | // ... 13 | } 14 | // Other options 15 | } 16 | ``` 17 | 18 | For an image to be excluded, it must start with one of the strings you specify above. That means you could use `ghcr.io` to exclude all images from ghcr.io or `ghcr.io/sergi0g` to exclude all my images (why would you do that?). 19 | 20 | 21 | If you want Cup to always check some extra images that aren't available locally, you can modify your config like this: 22 | ```jsonc 23 | { 24 | "images": { 25 | "extra": [ 26 | "mysql:8.0", 27 | "nextcloud:30" 28 | ] 29 | // ... 30 | } 31 | // Other options 32 | } 33 | ``` 34 | 35 | Note that you must specify images with version tags, otherwise Cup will exit with an error! -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | asIndexPage: true 3 | --- 4 | 5 | import { Steps, Callout, Cards } from "nextra/components"; 6 | import { 7 | IconPaint, 8 | IconLockOpen, 9 | IconKey, 10 | IconPlug, 11 | IconServer, 12 | } from "@tabler/icons-react"; 13 | 14 | # Configuration 15 | 16 | ## Custom docker socket 17 | 18 | Sometimes, there may be a need to specify a custom docker socket. Cup provides the `-s` option for this. 19 | 20 | For example, if using Podman, you might do 21 | 22 | ```bash 23 | $ cup -s /run/user/1000/podman/podman.sock check 24 | ``` 25 | 26 | This option is also available in the configuration file and it's best to put it there. 27 | 28 | <Cards.Card 29 | icon={<IconPlug />} 30 | title="Custom Docker socket" 31 | href="/docs/configuration/socket" 32 | /> 33 | 34 | ## Configuration file 35 | 36 | Cup has an option to be configured from a configuration file named `cup.json`. 37 | 38 | <Steps> 39 | ### Create the configuration file 40 | Create a `cup.json` file somewhere on your system. For binary installs, a path like `~/.config/cup.json` is recommended. 41 | If you're running with Docker, you can create a `cup.json` in the directory you're running Cup and mount it into the container. _In the next section you will need to use the path where you **mounted** the file_ 42 | 43 | ### Configure Cup from the configuration file 44 | 45 | Follow the guides below to customize your `cup.json` 46 | 47 | <Cards> 48 | <Cards.Card 49 | icon={<IconKey />} 50 | title="Authentication" 51 | href="/docs/configuration/authentication" 52 | /> 53 | <Cards.Card 54 | icon={<IconLockOpen />} 55 | title="Insecure registries" 56 | href="/docs/configuration/insecure-registries" 57 | /> 58 | <Cards.Card 59 | icon={<IconPaint />} 60 | title="Theme" 61 | href="/docs/configuration/theme" 62 | /> 63 | <Cards.Card 64 | icon={<IconServer />} 65 | title="Multiple servers" 66 | href="/docs/configuration/servers" 67 | /> 68 | </Cards> 69 | 70 | Here's a full example: 71 | 72 | ```json 73 | { 74 | "$schema": "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json", 75 | "version": 3, 76 | "images": { 77 | "exclude": ["ghcr.io/immich-app/immich-machine-learning"], 78 | "extra": ["ghcr.io/sergi0g/cup:v3.0.0"] 79 | }, 80 | "registries": { 81 | "myregistry.com": { 82 | "authentication": "<YOUR_TOKEN_HERE>" 83 | } 84 | }, 85 | "servers": { 86 | "Raspberry Pi": "https://server.local:8000" 87 | }, 88 | "theme": "blue" 89 | } 90 | ``` 91 | 92 | <Callout> 93 | If you want autocompletions and error checking for your editor, there is a 94 | JSON schema available. Use it by adding a `"$schema": 95 | "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json"` entry in 96 | your `cup.json` file. 97 | </Callout> 98 | 99 | ### Run Cup with the new configuration file 100 | 101 | To let Cup know that you'd like it to use a custom configuration file, you can use the `-c` flag, followed by the _absolute_ path of the file. 102 | 103 | ```bash 104 | $ cup -c /home/sergio/.config/cup.json check 105 | ``` 106 | 107 | ```bash 108 | $ docker run -tv /var/run/docker.sock:/var/run/docker.sock -v /home/sergio/.config/cup.json:/config/cup.json ghcr.io/sergi0g/cup -c /config/cup.json serve 109 | ``` 110 | 111 | </Steps> 112 | 113 | ## Environment Variables 114 | 115 | Want to make a quick change without editing your `config.json`? Cup also supports some configuration options from environment variables. 116 | Here are the ones currently available: 117 | - `CUP_AGENT` - Agent mode 118 | - `CUP_IGNORE_UPDATE_TYPE` - Ignoring specific update types 119 | - `CUP_REFRESH_INTERVAL` - Automatic refresh 120 | - `CUP_SOCKET` - Socket 121 | - `CUP_THEME` - Theme 122 | 123 | Refer to the configuration page for more information on each of these. 124 | 125 | Here's an example of a Docker Compose file using them: 126 | ```yaml 127 | services: 128 | cup: 129 | image: ghcr.io/sergi0g/cup:latest 130 | command: serve 131 | ports: 132 | - 8000:8000 133 | environment: 134 | - CUP_AGENT: true 135 | - CUP_IGNORE_UPDATE_TYPE: major 136 | - CUP_REFRESH_INTERVAL: "0 */30 * * * *" 137 | - CUP_SOCKET: tcp://localhost:2375 138 | - CUP_THEME: blue 139 | ``` 140 | 141 | <Callout> 142 | Heads up! 143 | Any configuration option you set with environment variables **always** overrides anything in your `cup.json`. 144 | </Callout> -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/insecure-registries.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Insecure registries 4 | 5 | For the best security, Cup only connects to registries over SSL (HTTPS) by default. However, for people running a local registry that doesn't support SSL, this may be a problem. 6 | 7 | To solve this problem, you can specify exceptions in your `cup.json`. 8 | 9 | Here's what it looks like: 10 | 11 | ```jsonc 12 | { 13 | "registries": { 14 | "<INSECURE_REGISTRY_1>": { 15 | "insecure": true 16 | // Other options 17 | }, 18 | "<INSECURE_REGISTRY_2>" { 19 | "insecure": true 20 | // Other options 21 | }, 22 | // ... 23 | } 24 | // Other options 25 | } 26 | ``` 27 | 28 | <Callout emoji="⚠️"> 29 | When configuring an insecure registry that doesn't run on port 80, don't 30 | forget to specify the port (i.e. use `localhost:5000` instead of `localhost` 31 | if your registry is running on port `5000`) 32 | </Callout> 33 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/servers.mdx: -------------------------------------------------------------------------------- 1 | # Multiple servers 2 | 3 | Besides checking for local image updates, you might want to be able to view update stats for all your servers running Cup in a central place. If you choose to add more servers to your Cup configuration, Cup will retrieve the current list of updates from your other servers and it will be included in the results. 4 | 5 | Just add something like this to your config: 6 | 7 | ```jsonc 8 | { 9 | "servers": { 10 | "Cool server 1": "http://your-other-server-running-cup:8000", 11 | "Other server": "http://and-another-one:9000" 12 | } 13 | // Other options 14 | } 15 | ``` -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/socket.mdx: -------------------------------------------------------------------------------- 1 | # Custom socket 2 | 3 | If you need to specify a custom Docker socket (e.g. because you're using Podman), you can use the `socket` option. Here's an example: 4 | 5 | ```jsonc 6 | { 7 | "socket": "/run/user/1000/podman/podman.sock" 8 | // Other options 9 | } 10 | ``` 11 | 12 | You can also specify a TCP socket if you're using a remote Docker host or a [proxy](https://github.com/Tecnativa/docker-socket-proxy): 13 | 14 | ```jsonc 15 | { 16 | "socket": "tcp://localhost:2375" 17 | // Other options 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/src/content/docs/configuration/theme.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | import Image from "next/image"; 3 | 4 | import blue from "@/app/assets/blue_theme.png"; 5 | import neutral from "@/app/assets/hero-dark.png"; 6 | 7 | # Theme 8 | 9 | <Callout emoji="⚠️">This configuration option is only for the server</Callout> 10 | 11 | Cup initially had a blue theme which looked like this: 12 | 13 | <Image alt="Screenshot of blue theme" src={blue} /> 14 | 15 | This was replaced by a more neutral theme which is now the default: 16 | 17 | <Image alt="Screenshot of neutral theme" src={neutral} /> 18 | 19 | However, you can get the old theme back by adding the `theme` key to your `cup.json` 20 | Available options are `default` and `blue`. 21 | 22 | Here's an example: 23 | 24 | ```jsonc 25 | { 26 | "theme": "blue" 27 | // Other options 28 | } 29 | ``` 30 | 31 | Note that the difference between the 2 themes is almost impossible to perceive when your system is in light mode. 32 | -------------------------------------------------------------------------------- /docs/src/content/docs/contributing.mdx: -------------------------------------------------------------------------------- 1 | import { Steps } from "nextra/components"; 2 | 3 | # Contributing 4 | 5 | First of all, thanks for taking time to contribute to Cup! This guide will help you set up a development environment and make your first contribution. 6 | 7 | ## Setting up a development environment 8 | 9 | Requirements: 10 | 11 | - A computer running Linux 12 | - Rust (usually installed from https://rustup.rs/) 13 | - Node.js 22+ and Bun 1+ 14 | 15 | <Steps> 16 | ### Fork the repository 17 | This is where you'll be pushing your changes before you create a pull request. Make sure to _create a new branch_ for your changes. 18 | ### Clone your fork 19 | ```bash 20 | git clone https://github.com/<YOUR_USERNAME>/cup 21 | ``` 22 | If you use SSH: 23 | ```bash 24 | git clone git@github.com:<YOUR_USERNAME>/cup`) 25 | ``` 26 | ### Switch to your newly created branch (e.g. if your branch is called `improve-logging`, run `git checkout improve-logging`) 27 | ### Set up the frontend 28 | ```bash 29 | $ cd web 30 | $ bun install 31 | $ cd .. 32 | $ ./build.sh 33 | ``` 34 | </Steps> 35 | 36 | You're ready to go! 37 | 38 | ## Project architecture 39 | 40 | Cup can be run in 2 modes: CLI and server. 41 | 42 | All CLI specific functionality is located in `src/formatting.rs` and some other files in functions prefixed with `#[cfg(feature = "cli")]`. 43 | 44 | All server specific functionality is located in `src/server.rs` and `web/`. 45 | 46 | ## Important notes 47 | 48 | - When making any changes, always make sure to write optimize your code for: 49 | 50 | - Performance: You should always benchmark Cup before making changes and after your changes to make sure there is none (or a very small) difference in time. Profiling old and new code is also good. 51 | - Readability: Include comments describing any new functions you create, give descriptive names to variables and when making a design decision or a compromise, ALWAYS include a comment explaining what you did and why. 52 | 53 | - If you plan on developing the frontend without making backend changes, it is highly recommended to run `cup serve` in the background and start the frontend in development mode from `web/` with `bun dev`. 54 | 55 | - If you make changes to the frontend, always remember to prefix your build command with the `build.sh` script which takes care of rebuilding the frontend. For example: `./build.sh cargo build -r` 56 | 57 | - When adding new features to Cup (e.g. configuration options), make sure to update the documentation (located in `docs/`). Refer to other pages in the documentation, or to the [official docs](https://nextra.site) for any questions you may have. The docs use `pnpm` as their package manager. 58 | 59 | - If you need help with finishing something (e.g. you've made some commits and need help with writing docs, you want some feedback about a design decision, etc.), you can open a draft PR and ask for help there. 60 | 61 | ## Submitting a PR 62 | 63 | To have your changes included in Cup, you will need to create a pull request. 64 | 65 | Before doing so, please make sure you have run `cargo clippy` and resolved all warnings related to your changes and have formatted your code with `cargo fmt`. This ensures Cup's codebase is consistent and uses good practices for code. 66 | 67 | After you're done with that, commit your changes and push them to your branch. 68 | 69 | Next, open your fork on Github and create a pull request. Make sure to include the changes you made, which issues it addresses (if any) and any other info you think is important. 70 | 71 | Happy contributing! 72 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import cup from "@/app/assets/cup.gif"; 3 | import { Cards } from "nextra/components"; 4 | import { IconBrandDocker, IconPackage } from "@tabler/icons-react"; 5 | 6 | # Introduction 7 | 8 | <Image src={cup} alt="Animated GIF of Cup's CLI in action" unoptimized /> 9 | 10 | Cup is a lightweight alternative to [What's up Docker?](https://github.com/getwud/wud) written in Rust. 11 | 12 | # Features ✨ 13 | 14 | - 🚀 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! 15 | - Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives) 16 | - 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. 17 | - Beautiful CLI and web interface for checking on your containers any time. 18 | - 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. 19 | - 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! 20 | 21 | # Installation 22 | 23 | <Cards> 24 | <Cards.Card 25 | icon={<IconBrandDocker />} 26 | title="With Docker" 27 | href="/docs/installation/docker" 28 | /> 29 | <Cards.Card 30 | icon={<IconPackage />} 31 | title="As a binary" 32 | href="/docs/installation/binary" 33 | /> 34 | </Cards> 35 | -------------------------------------------------------------------------------- /docs/src/content/docs/installation/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | docker: { 3 | title: "With Docker", 4 | }, 5 | binary: { 6 | title: "As a binary", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/src/content/docs/installation/binary.mdx: -------------------------------------------------------------------------------- 1 | import { Callout, Cards, Steps } from "nextra/components"; 2 | import { IconFileDescription } from "@tabler/icons-react"; 3 | 4 | # As a binary 5 | 6 | ## Introduction 7 | 8 | This guide will help you install Cup from a binary. 9 | 10 | ## Installation 11 | 12 | <Steps> 13 | ### Download binary 14 | Go to https://github.com/sergi0g/cup/releases/latest. 15 | 16 | Depending on your system's architecture, choose the binary for your system. For example, for an `x86_64` machine, you should download `cup-x86_64-unknown-linux-musl` 17 | 18 | <Callout> 19 | You can use the command `uname -i` to find this 20 | </Callout> 21 | ### Add binary to path 22 | Move the binary you downloaded to a directory in your path. You can usually get a list those directories by running `echo $PATH`. On most Linux systems, moving it to `~/.local/bin` is usually enough. 23 | </Steps> 24 | 25 | That's it! Cup is ready to be used. Head over to the Usage page to get started. 26 | 27 | <br /> 28 | <Cards.Card icon={<IconFileDescription />} title="Usage" href="/docs/usage" /> 29 | -------------------------------------------------------------------------------- /docs/src/content/docs/installation/docker.mdx: -------------------------------------------------------------------------------- 1 | import { Callout, Cards } from "nextra/components"; 2 | import { IconFileDescription } from "@tabler/icons-react"; 3 | 4 | # With Docker 5 | 6 | ## Introduction 7 | 8 | This guide will help you install Cup as a Docker container. It is the easiest installation method and also makes updating Cup very easy. 9 | 10 | ## Installation 11 | 12 | To get started, open up a terminal and run the following command. 13 | 14 | ```bash 15 | $ docker pull ghcr.io/sergi0g/cup 16 | ``` 17 | 18 | <Callout emoji="⚠️"> 19 | If you aren't a member of the `docker` group, please ensure you run all 20 | commands as a user who is. In most cases, you'll just need to prefix the 21 | `docker` commands with `sudo` 22 | </Callout> 23 | 24 | That's it! Cup is ready to be used. Head over to the Usage page to get started. 25 | 26 | <br /> 27 | <Cards.Card icon={<IconFileDescription />} title="Usage" href="/docs/usage" /> 28 | -------------------------------------------------------------------------------- /docs/src/content/docs/integrations.mdx: -------------------------------------------------------------------------------- 1 | import { Callout, Cards } from "nextra/components"; 2 | import { IconServer, IconTerminal } from "@tabler/icons-react"; 3 | 4 | # Integrations 5 | 6 | At the moment, Cup has no built-in integrations, but it provides an API for the server and JSON output for the CLI, which can enable you to connect Cup to your own integrations. 7 | 8 | ## JSON data 9 | 10 | The data returned from the API or from the CLI is in JSON and looks like this: 11 | 12 | ```jsonc 13 | { 14 | // Statistics useful for displaying on dashboards. 15 | // You could calculate these yourself based on the rest of the data, 16 | // but they're provided for easier integration with other systems. 17 | "metrics": { 18 | "monitored_images": 5, 19 | "up_to_date": 2, 20 | "updates_available": 3, 21 | "major_updates": 1, 22 | "minor_updates": 0, 23 | "patch_updates": 0, 24 | "other_updates": 2, 25 | "unknown": 0, 26 | }, 27 | // A list of image objects with all related information. 28 | "images": [ 29 | { 30 | "reference": "ghcr.io/sergi0g/cup:latest", 31 | "parts": { 32 | // The information Cup extracted about the image from the reference. Mostly useful for debugging and the way the web interface works. 33 | "registry": "ghcr.io", 34 | "repository": "sergi0g/cup", 35 | "tag": "latest", 36 | }, 37 | "url": "https://github.com/sergi0g/cup", // The URL specified in the "org.opencontainers.image.url" label, otherwise null 38 | "result": { 39 | "has_update": true, // `true` when an image has an update of any kind, `false` when up to date and `null` when unknown. 40 | "info": { 41 | // `null` if up to date 42 | "type": "digest", // Can also be `version` when Cup detects the tag contains a version. 43 | // If `type` is "digest": 44 | "local_digests": [ 45 | // A list of local digests present for the image 46 | "sha256:b7168e5f6828cbbd3622fa19965007e4611cf42b5f3c603008377ffd45a4fe00", 47 | ], 48 | "remote_digest": "sha256:170f1974d8fc8ca245bcfae5590bc326de347b19719972bf122400fb13dfa42c", // Latest digest available in the registry 49 | // If `type` is "version": 50 | "version_update_type": "major", // Loosely corresponds to SemVer versioning. Can also be `minor` or `patch`. 51 | "new_tag": "v3.3.3", // The tag of the latest image. 52 | }, 53 | "error": null, // If checking for the image fails, will be a string with an error message. 54 | }, 55 | "time": 869, // Time in milliseconds it took to check for the update. Useful for debugging. 56 | "server": "Lithium", // The name of the server which the image was checked for updates on. `null` if from the current machine. 57 | }, 58 | ], 59 | } 60 | ``` 61 | 62 | <Callout emoji="⚠️"> 63 | Please keep in mind that the above may not always be up to date. New fields 64 | may be added, or some types extended. If you notice that, just open an issue 65 | and they'll be updated. Changes to the JSON data schema will _always_ happen 66 | in a backwards-compatible way. In case backwards-incompatible changes are 67 | made, these docs will be updated. For something more up-to-date, you can 68 | take a look at https://github.com/sergi0g/cup/blob/main/web/src/types.ts 69 | </Callout> 70 | 71 | For retrieving the above data, refer to the CLI and server pages: 72 | 73 | <Cards> 74 | <Cards.Card icon={<IconTerminal />} title="CLI" href="/docs/usage/cli" /> 75 | <Cards.Card 76 | icon={<IconServer />} 77 | title="Server" 78 | href="/docs/usage/server" 79 | /> 80 | </Cards> 81 | 82 | ## Refresh Cup 83 | 84 | 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. 85 | -------------------------------------------------------------------------------- /docs/src/content/docs/nightly.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Using the latest version 4 | 5 | The installation instructions you previously followed describe how to install Cup's stable version. 6 | 7 | However, it is only updated when a new release is created, so if you want the latest features, you'll need to install Cup's nightly version. 8 | 9 | Cup's nightly version always contains the latest changes in the main branch. 10 | 11 | <Callout emoji="⚠️"> 12 | There is no guarantee that the nightly version will always work. There may be 13 | breaking changes or a bad commit and it may not work properly. Install nightly 14 | only if you know what you are doing. These instructions will assume you have 15 | the technical know-how to follow them. If you do not, please use the stable 16 | release! 17 | </Callout> 18 | 19 | ## With Docker 20 | 21 | Instead of `ghcr.io/sergi0g/cup`, use `ghcr.io/sergi0g/cup:nightly` 22 | 23 | ## As a binary 24 | 25 | Go to a [nightly workflow run](https://github.com/sergi0g/cup/actions/workflows/nightly.yml) and download the artifact for your system. 26 | -------------------------------------------------------------------------------- /docs/src/content/docs/usage/cli.mdx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import cup from "@/app/assets/cup.gif"; 3 | import { Callout } from "nextra/components"; 4 | 5 | # CLI 6 | 7 | Cup's CLI provides the `cup check` command. 8 | 9 | ## Basic Usage 10 | 11 | ### Check for all updates 12 | 13 | ```ansi 14 | $ cup check 15 | ✓ Done! 16 | ~ Local images 17 | ╭─────────────────────────────────────────┬──────────────────────────────────┬─────────╮ 18 | │Reference │Status │Time (ms)│ 19 | ├─────────────────────────────────────────┼──────────────────────────────────┼─────────┤ 20 | │postgres:15-alpine │Major update (15 → 17) │788 │ 21 | │ghcr.io/immich-app/immich-server:v1.118.2│Minor update (1.118.2 → 1.127.0) │2294 │ 22 | │ollama/ollama:0.4.1 │Minor update (0.4.1 → 0.5.12) │533 │ 23 | │adguard/adguardhome:v0.107.52 │Patch update (0.107.52 → 0.107.57)│1738 │ 24 | │jc21/nginx-proxy-manager:latest │Up to date │583 │ 25 | │louislam/uptime-kuma:1 │Up to date │793 │ 26 | │moby/buildkit:buildx-stable-1 │Up to date │600 │ 27 | │tecnativa/docker-socket-proxy:latest │Up to date │564 │ 28 | │ubuntu:latest │Up to date │585 │ 29 | │wagoodman/dive:latest │Up to date │585 │ 30 | │rolebot:latest │Unknown │174 │ 31 | ╰─────────────────────────────────────────┴──────────────────────────────────┴─────────╯ 32 |  INFO ✨ Checked 11 images in 8312ms 33 | ``` 34 | 35 | ### Check for updates to specific images 36 | 37 | ```ansi 38 | $ cup check node:latest 39 | ✓ Done! 40 | ~ Local images 41 | ╭───────────┬────────────────┬─────────╮ 42 | │Reference │Status │Time (ms)│ 43 | ├───────────┼────────────────┼─────────┤ 44 | │node:latest│Update available│788 │ 45 | ╰───────────┴────────────────┴─────────╯ 46 |  INFO ✨ Checked 1 images in 310ms 47 | ``` 48 | 49 | ```ansi 50 | $ cup check nextcloud:30 postgres:14 mysql:8.0 51 | ✓ Done! 52 | ~ Local images 53 | ╭────────────┬────────────────────────┬─────────╮ 54 | │Reference │Status │Time (ms)│ 55 | ├────────────┼────────────────────────┼─────────┤ 56 | │postgres:14 │Major update (14 → 17) │195 │ 57 | │mysql:8.0 │Major update (8.0 → 9.2)│382 │ 58 | │nextcloud:30│Up to date │585 │ 59 | ╰────────────┴────────────────────────┴─────────╯ 60 |  INFO ✨ Checked 3 images in 769ms 61 | ``` 62 | 63 | ## Enable icons 64 | 65 | You can also enable icons if you have a [Nerd Font](https://nerdfonts.com) installed. 66 | 67 | <Image src={cup} alt="GIF of Cup's CLI" unoptimized /> 68 | 69 | ## JSON output 70 | 71 | When integrating Cup with other services (e.g. webhooks or a dashboard), you may find Cup's JSON output functionality useful. 72 | 73 | It provides some useful metrics (see [server](/docs/usage/server) for more information), along with a list of images and whether they have an update or not. Note that at the moment it does not match the detailed API the server provides. 74 | 75 | ``` 76 | $ cup check -r 77 | {"metrics":{"monitored_images":26,"up_to_date":2,"updates_available":23,"major_updates":8,"minor_updates":6,"patch_updates":2,"other_updates":7,"unknown":1},"images":{"ghcr.io/immich-app/immich-server:v1.106.4":false,"portainer/portainer-ce:2.20.3-alpine":false,"ghcr.io/runtipi/runtipi:v3.4.1":false,...}} 78 | ``` 79 | 80 | <Callout emoji="⚠️"> 81 | When parsing Cup's output, capture only `stdout`, otherwise you might not get 82 | valid JSON (if there are warnings) 83 | </Callout> 84 | 85 | ## Usage with Docker 86 | 87 | If you're using the Docker image, just replace all occurences of `cup` in the examples with `docker run -tv /var/run/docker.sock:/var/run/docker.sock ghcr.io/sergi0g/cup`. 88 | 89 | For example, this: 90 | 91 | ```bash 92 | $ cup check node:latest 93 | ``` 94 | 95 | becomes: 96 | 97 | ```bash 98 | $ docker run -tv /var/run/docker.sock:/var/run/docker.sock ghcr.io/sergi0g/cup check node:latest 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/src/content/docs/usage/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | asIndexPage: true 3 | --- 4 | 5 | import { IconServer, IconTerminal } from "@tabler/icons-react"; 6 | import { Cards } from "nextra/components"; 7 | 8 | # Usage 9 | 10 | You can use Cup in 2 different ways. As a CLI or as a server. You can learn more about each mode on its corresponding page 11 | 12 | <Cards> 13 | <Cards.Card icon={<IconTerminal />} title="CLI" href="/docs/usage/cli" /> 14 | <Cards.Card icon={<IconServer />} title="Server" href="/docs/usage/server" /> 15 | </Cards> 16 | -------------------------------------------------------------------------------- /docs/src/content/docs/usage/server.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra/components"; 2 | 3 | # Server 4 | 5 | The server provides the `cup serve` command. 6 | 7 | ## Basic usage 8 | 9 | ```ansi 10 | $ cup serve 11 |  INFO Starting server, please wait... 12 |  INFO ✨ Checked 8 images in 8862ms 13 |  INFO Ready to start! 14 |  HTTP GET / 200 in 0ms 15 |  HTTP GET /assets/index.js 200 in 3ms 16 |  HTTP GET /assets/index.css 200 in 0ms 17 |  HTTP GET /api/v3/json 200 in 0ms 18 | ``` 19 | 20 | This will launch the server on port `8000`. To access it, visit `http://<YOUR_IP>:8000` (replace `<YOUR_IP>` with the IP address of the machine running Cup.) 21 | 22 | <Callout> 23 | The URL `http://<YOUR_IP>:8000/api/v3/json` is also available for usage with integrations. 24 | </Callout> 25 | 26 | ## Use a different port 27 | 28 | Pass the `-p` argument with the port you want to use 29 | 30 | ```ansi 31 | $ cup serve -p 9000 32 |  INFO Starting server, please wait... 33 |  INFO ✨ Checked 8 images in 8862ms 34 |  INFO Ready to start! 35 |  HTTP GET / 200 in 0ms 36 |  HTTP GET /assets/index.js 200 in 3ms 37 |  HTTP GET /assets/index.css 200 in 0ms 38 |  HTTP GET /api/v3/json 200 in 0ms 39 | ``` 40 | 41 | ## Usage with Docker 42 | 43 | If you're using the Docker image, just replace all occurences of `cup` in the examples with `docker run -tv /var/run/docker.sock:/var/run/docker.sock -p <PORT>:<PORT> ghcr.io/sergi0g/cup`, where `<PORT>` is the port Cup will be using. 44 | 45 | For example, this: 46 | 47 | ```bash 48 | $ cup serve -p 9000 49 | ``` 50 | 51 | becomes: 52 | 53 | ```bash 54 | $ docker run -tv /var/run/docker.sock:/var/run/docker.sock -p 9000:9000 ghcr.io/sergi0g/cup serve -p 9000 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/src/mdx-components.ts: -------------------------------------------------------------------------------- 1 | import { useMDXComponents as getThemeComponents } from "nextra-theme-docs"; 2 | import { MDXComponents } from "nextra/mdx-components"; 3 | 4 | // Get the default MDX components 5 | const themeComponents = getThemeComponents(); 6 | 7 | // Merge components 8 | export function useMDXComponents(components: MDXComponents) { 9 | return { 10 | ...themeComponents, 11 | ...components, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /screenshots/cup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/screenshots/cup.gif -------------------------------------------------------------------------------- /screenshots/web_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/screenshots/web_dark.png -------------------------------------------------------------------------------- /screenshots/web_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/screenshots/web_light.png -------------------------------------------------------------------------------- /src/check.rs: -------------------------------------------------------------------------------- 1 | use futures::future::join_all; 2 | use itertools::Itertools; 3 | use rustc_hash::{FxHashMap, FxHashSet}; 4 | 5 | use crate::{ 6 | docker::{get_images_from_docker_daemon, get_in_use_images}, 7 | http::Client, 8 | registry::{check_auth, get_token}, 9 | structs::{image::Image, update::Update}, 10 | utils::request::{get_response_body, parse_json}, 11 | Context, 12 | }; 13 | 14 | /// Fetches image data from other Cup instances 15 | async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Vec<Update> { 16 | let mut remote_images = Vec::new(); 17 | 18 | let handles: Vec<_> = ctx.config.servers 19 | .iter() 20 | .map(|(name, url)| async move { 21 | let base_url = if url.starts_with("http://") || url.starts_with("https://") { 22 | format!("{}/api/v3/", url.trim_end_matches('/')) 23 | } else { 24 | format!("https://{}/api/v3/", url.trim_end_matches('/')) 25 | }; 26 | let json_url = base_url.clone() + "json"; 27 | if refresh { 28 | let refresh_url = base_url + "refresh"; 29 | match client.get(&refresh_url, &[], false).await { 30 | Ok(response) => { 31 | if response.status() != 200 { 32 | ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status())); 33 | return Vec::new(); 34 | } 35 | }, 36 | Err(e) => { 37 | ctx.logger.warn(format!("GET {}: Failed to refresh server. {}", refresh_url, e)); 38 | return Vec::new(); 39 | }, 40 | } 41 | 42 | } 43 | match client.get(&json_url, &[], false).await { 44 | Ok(response) => { 45 | if response.status() != 200 { 46 | ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}", json_url, response.status())); 47 | return Vec::new(); 48 | } 49 | let json = parse_json(&get_response_body(response).await); 50 | ctx.logger.debug(format!("JSON response for {}: {}", name, json)); 51 | if let Some(updates) = json["images"].as_array() { 52 | let mut server_updates: Vec<Update> = updates 53 | .iter() 54 | .filter_map(|img| serde_json::from_value(img.clone()).ok()) 55 | .collect(); 56 | // Add server origin to each image 57 | for update in &mut server_updates { 58 | update.server = Some(name.clone()); 59 | update.status = update.get_status(); 60 | } 61 | ctx.logger.debug(format!("Updates for {}: {:#?}", name, server_updates)); 62 | return server_updates; 63 | } 64 | 65 | Vec::new() 66 | } 67 | Err(e) => { 68 | ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. {}", json_url, e)); 69 | Vec::new() 70 | }, 71 | } 72 | }) 73 | .collect(); 74 | 75 | for mut images in join_all(handles).await { 76 | remote_images.append(&mut images); 77 | } 78 | 79 | remote_images 80 | } 81 | 82 | /// Returns a list of updates for all images passed in. 83 | pub async fn get_updates( 84 | references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value 85 | refresh: bool, 86 | ctx: &Context, 87 | ) -> Vec<Update> { 88 | let client = Client::new(ctx); 89 | 90 | // Merge references argument with references from config 91 | let all_references = match &references { 92 | Some(refs) => { 93 | if !ctx.config.images.extra.is_empty() { 94 | refs.clone().extend_from_slice(&ctx.config.images.extra); 95 | } 96 | refs 97 | } 98 | None => &ctx.config.images.extra, 99 | }; 100 | 101 | // Get local images 102 | ctx.logger.debug("Retrieving images to be checked"); 103 | let mut images = get_images_from_docker_daemon(ctx, references).await; 104 | let in_use_images = get_in_use_images(ctx).await; 105 | ctx.logger 106 | .debug(format!("Found {} images in use", in_use_images.len())); 107 | 108 | // Complete in_use field 109 | images.iter_mut().for_each(|image| { 110 | if in_use_images.contains(&image.reference) { 111 | image.in_use = true 112 | } 113 | }); 114 | 115 | // Add extra images from references 116 | if !all_references.is_empty() { 117 | let image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect(); 118 | let extra = all_references 119 | .iter() 120 | .filter(|&reference| !image_refs.contains(reference)) 121 | .map(|reference| Image::from_reference(reference)) 122 | .collect::<Vec<Image>>(); 123 | images.extend(extra); 124 | } 125 | 126 | // Get remote images from other servers 127 | let remote_updates = if !ctx.config.servers.is_empty() { 128 | ctx.logger.debug("Fetching updates from remote servers"); 129 | get_remote_updates(ctx, &client, refresh).await 130 | } else { 131 | Vec::new() 132 | }; 133 | 134 | ctx.logger.debug(format!( 135 | "Checking {:?}", 136 | images.iter().map(|image| &image.reference).collect_vec() 137 | )); 138 | 139 | // Get a list of unique registries our images belong to. We are unwrapping the registry because it's guaranteed to be there. 140 | let registries: Vec<&String> = images 141 | .iter() 142 | .map(|image| &image.parts.registry) 143 | .unique() 144 | .filter(|®istry| match ctx.config.registries.get(registry) { 145 | Some(config) => !config.ignore, 146 | None => true, 147 | }) 148 | .collect::<Vec<&String>>(); 149 | 150 | // Create request client. All network requests share the same client for better performance. 151 | // This client is also configured to retry a failed request up to 3 times with exponential backoff in between. 152 | let client = Client::new(ctx); 153 | 154 | // Create a map of images indexed by registry. This solution seems quite inefficient, since each iteration causes a key to be looked up. I can't find anything better at the moment. 155 | let mut image_map: FxHashMap<&String, Vec<&Image>> = FxHashMap::default(); 156 | 157 | for image in &images { 158 | image_map 159 | .entry(&image.parts.registry) 160 | .or_default() 161 | .push(image); 162 | } 163 | 164 | // Retrieve an authentication token (if required) for each registry. 165 | let mut tokens: FxHashMap<&str, Option<String>> = FxHashMap::default(); 166 | for registry in registries.clone() { 167 | let credentials = if let Some(registry_config) = ctx.config.registries.get(registry) { 168 | ®istry_config.authentication 169 | } else { 170 | &None 171 | }; 172 | match check_auth(registry, ctx, &client).await { 173 | Some(auth_url) => { 174 | let token = get_token( 175 | image_map.get(registry).unwrap(), 176 | &auth_url, 177 | credentials, 178 | &client, 179 | ) 180 | .await; 181 | tokens.insert(registry, Some(token)); 182 | } 183 | None => { 184 | tokens.insert(registry, None); 185 | } 186 | } 187 | } 188 | 189 | ctx.logger.debug(format!("Tokens: {:?}", tokens)); 190 | 191 | let mut handles = Vec::with_capacity(images.len()); 192 | 193 | // Loop through images check for updates 194 | for image in &images { 195 | let is_ignored = !registries.contains(&&image.parts.registry) 196 | || ctx 197 | .config 198 | .images 199 | .exclude 200 | .iter() 201 | .any(|item| image.reference.starts_with(item)); 202 | if !is_ignored { 203 | let token = tokens.get(image.parts.registry.as_str()).unwrap(); 204 | let future = image.check(token.as_deref(), ctx, &client); 205 | handles.push(future); 206 | } 207 | } 208 | // Await all the futures 209 | let images = join_all(handles).await; 210 | let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect(); 211 | updates.extend_from_slice(&remote_updates); 212 | updates 213 | } 214 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use rustc_hash::FxHashMap; 2 | use serde::Deserialize; 3 | use serde::Deserializer; 4 | use std::env; 5 | use std::mem; 6 | use std::path::PathBuf; 7 | 8 | use crate::error; 9 | 10 | // We can't assign `a` to `b` in the loop in `Config::load`, so we'll have to use swap. It looks ugly so now we have a macro for it. 11 | macro_rules! swap { 12 | ($a:expr, $b:expr) => { 13 | mem::swap(&mut $a, &mut $b) 14 | }; 15 | } 16 | 17 | #[derive(Clone, Deserialize)] 18 | #[serde(rename_all = "snake_case")] 19 | pub enum Theme { 20 | Default, 21 | Blue, 22 | } 23 | 24 | impl Default for Theme { 25 | fn default() -> Self { 26 | Self::Default 27 | } 28 | } 29 | 30 | #[derive(Clone, Deserialize)] 31 | #[serde(rename_all = "snake_case")] 32 | pub enum UpdateType { 33 | None, 34 | Major, 35 | Minor, 36 | Patch, 37 | } 38 | 39 | impl Default for UpdateType { 40 | fn default() -> Self { 41 | Self::None 42 | } 43 | } 44 | 45 | #[derive(Clone, Deserialize, Default)] 46 | #[serde(deny_unknown_fields)] 47 | #[serde(default)] 48 | pub struct RegistryConfig { 49 | pub authentication: Option<String>, 50 | pub insecure: bool, 51 | pub ignore: bool, 52 | } 53 | 54 | #[derive(Clone, Deserialize, Default)] 55 | #[serde(default)] 56 | pub struct ImageConfig { 57 | pub extra: Vec<String>, 58 | pub exclude: Vec<String>, 59 | } 60 | 61 | #[derive(Clone, Deserialize)] 62 | #[serde(default)] 63 | pub struct Config { 64 | version: u8, 65 | pub agent: bool, 66 | pub ignore_update_type: UpdateType, 67 | pub images: ImageConfig, 68 | #[serde(deserialize_with = "empty_as_none")] 69 | pub refresh_interval: Option<String>, 70 | pub registries: FxHashMap<String, RegistryConfig>, 71 | pub servers: FxHashMap<String, String>, 72 | pub socket: Option<String>, 73 | pub theme: Theme, 74 | } 75 | 76 | impl Config { 77 | pub fn new() -> Self { 78 | Self { 79 | version: 3, 80 | agent: false, 81 | ignore_update_type: UpdateType::default(), 82 | images: ImageConfig::default(), 83 | refresh_interval: None, 84 | registries: FxHashMap::default(), 85 | servers: FxHashMap::default(), 86 | socket: None, 87 | theme: Theme::Default, 88 | } 89 | } 90 | 91 | /// Loads file and env config and merges them 92 | pub fn load(&mut self, path: Option<PathBuf>) -> Self { 93 | let mut config = self.load_file(path); 94 | 95 | // Get environment variables with CUP_ prefix 96 | let env_vars: FxHashMap<String, String> = 97 | env::vars().filter(|(k, _)| k.starts_with("CUP_")).collect(); 98 | 99 | if !env_vars.is_empty() { 100 | if let Ok(mut cfg) = envy::prefixed("CUP_").from_env::<Config>() { 101 | // If we have environment variables, override config.json options 102 | for (key, _) in env_vars { 103 | match key.as_str() { 104 | "CUP_AGENT" => config.agent = cfg.agent, 105 | #[rustfmt::skip] 106 | "CUP_IGNORE_UPDATE_TYPE" => swap!(config.ignore_update_type, cfg.ignore_update_type), 107 | #[rustfmt::skip] 108 | "CUP_REFRESH_INTERVAL" => swap!(config.refresh_interval, cfg.refresh_interval), 109 | "CUP_SOCKET" => swap!(config.socket, cfg.socket), 110 | "CUP_THEME" => swap!(config.theme, cfg.theme), 111 | // The syntax for these is slightly more complicated, not sure if they should be enabled or not. Let's stick to simple types for now. 112 | // "CUP_IMAGES" => swap!(config.images, cfg.images), 113 | // "CUP_REGISTRIES" => swap!(config.registries, cfg.registries), 114 | // "CUP_SERVERS" => swap!(config.servers, cfg.servers), 115 | _ => (), // Maybe print a warning if other CUP_ variables are detected 116 | } 117 | } 118 | } 119 | } 120 | 121 | config 122 | } 123 | 124 | /// Reads the config from the file path provided and returns the parsed result. 125 | fn load_file(&self, path: Option<PathBuf>) -> Self { 126 | let raw_config = match &path { 127 | Some(path) => std::fs::read_to_string(path), 128 | None => return Self::new(), // Empty config 129 | }; 130 | if raw_config.is_err() { 131 | error!( 132 | "Failed to read config file from {}. Are you sure the file exists?", 133 | &path.unwrap().to_str().unwrap() 134 | ) 135 | }; 136 | self.parse(&raw_config.unwrap()) // We can safely unwrap here 137 | } 138 | /// Parses and validates the config. 139 | fn parse(&self, raw_config: &str) -> Self { 140 | let config: Self = match serde_json::from_str(raw_config) { 141 | Ok(config) => config, 142 | Err(e) => error!("Unexpected error occured while parsing config: {}", e), 143 | }; 144 | if config.version != 3 { 145 | error!("You are trying to run Cup with an incompatible config file! Please migrate your config file to the version 3, or if you have already done so, add a `version` key with the value `3`.") 146 | } 147 | config 148 | } 149 | } 150 | 151 | impl Default for Config { 152 | fn default() -> Self { 153 | Self::new() 154 | } 155 | } 156 | 157 | fn empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> 158 | where 159 | D: Deserializer<'de>, 160 | { 161 | let s = String::deserialize(deserializer)?; 162 | if s.is_empty() { 163 | Ok(None) 164 | } else { 165 | Ok(Some(s)) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/docker.rs: -------------------------------------------------------------------------------- 1 | use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker}; 2 | 3 | use futures::future::join_all; 4 | 5 | use crate::{error, structs::image::Image, Context}; 6 | 7 | fn create_docker_client(socket: Option<&str>) -> Docker { 8 | let client: Result<Docker, bollard::errors::Error> = match socket { 9 | Some(sock) => { 10 | if sock.starts_with("unix://") { 11 | Docker::connect_with_unix( 12 | sock, 13 | 120, 14 | &ClientVersion { 15 | major_version: 1, 16 | minor_version: 44, 17 | }, 18 | ) 19 | } else { 20 | Docker::connect_with_http( 21 | sock, 22 | 120, 23 | &ClientVersion { 24 | major_version: 1, 25 | minor_version: 44, 26 | }, 27 | ) 28 | } 29 | } 30 | None => Docker::connect_with_unix_defaults(), 31 | }; 32 | 33 | match client { 34 | Ok(d) => d, 35 | Err(e) => error!("Failed to connect to docker daemon!\n{}", e), 36 | } 37 | } 38 | 39 | /// Retrieves images from Docker daemon. If `references` is Some, return only the images whose references match the ones specified. 40 | pub async fn get_images_from_docker_daemon( 41 | ctx: &Context, 42 | references: &Option<Vec<String>>, 43 | ) -> Vec<Image> { 44 | let client: Docker = create_docker_client(ctx.config.socket.as_deref()); 45 | let mut swarm_images = match client.list_services::<String>(None).await { 46 | Ok(services) => services 47 | .iter() 48 | .filter_map(|service| match &service.spec { 49 | Some(service_spec) => match &service_spec.task_template { 50 | Some(task_spec) => match &task_spec.container_spec { 51 | Some(container_spec) => match &container_spec.image { 52 | Some(image) => Image::from_inspect_data(ctx, image), 53 | None => None, 54 | }, 55 | None => None, 56 | }, 57 | None => None, 58 | }, 59 | None => None, 60 | }) 61 | .collect(), 62 | Err(_) => Vec::new(), 63 | }; 64 | let mut local_images = match references { 65 | Some(refs) => { 66 | let mut inspect_handles = Vec::with_capacity(refs.len()); 67 | for reference in refs { 68 | inspect_handles.push(client.inspect_image(reference)); 69 | } 70 | let inspects: Vec<ImageInspect> = join_all(inspect_handles) 71 | .await 72 | .iter() 73 | .filter(|inspect| inspect.is_ok()) 74 | .map(|inspect| inspect.as_ref().unwrap().clone()) 75 | .collect(); 76 | inspects 77 | .iter() 78 | .filter_map(|inspect| Image::from_inspect_data(ctx, inspect.clone())) 79 | .collect() 80 | } 81 | None => { 82 | let images = match client.list_images::<String>(None).await { 83 | Ok(images) => images, 84 | Err(e) => { 85 | error!("Failed to retrieve list of images available!\n{}", e) 86 | } 87 | }; 88 | images 89 | .iter() 90 | .filter_map(|image| Image::from_inspect_data(ctx, image.clone())) 91 | .collect::<Vec<Image>>() 92 | } 93 | }; 94 | local_images.append(&mut swarm_images); 95 | local_images 96 | } 97 | 98 | pub async fn get_in_use_images(ctx: &Context) -> Vec<String> { 99 | let client: Docker = create_docker_client(ctx.config.socket.as_deref()); 100 | 101 | let containers = match client 102 | .list_containers::<String>(Some(ListContainersOptions { 103 | all: true, 104 | ..Default::default() 105 | })) 106 | .await 107 | { 108 | Ok(containers) => containers, 109 | Err(e) => { 110 | error!("Failed to retrieve list of containers available!\n{}", e) 111 | } 112 | }; 113 | 114 | containers 115 | .iter() 116 | .filter_map(|container| match &container.image { 117 | Some(image) => Some({ 118 | if image.contains(":") { 119 | image.clone() 120 | } else { 121 | format!("{image}:latest") 122 | } 123 | }), 124 | None => None, 125 | }) 126 | .collect() 127 | } 128 | -------------------------------------------------------------------------------- /src/formatting/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod spinner; 2 | 3 | use rustc_hash::FxHashMap; 4 | 5 | use crate::{ 6 | structs::{ 7 | status::Status, 8 | update::{Update, UpdateInfo}, 9 | }, 10 | utils::{json::to_simple_json, sort_update_vec::sort_update_vec}, 11 | }; 12 | 13 | pub fn print_updates(updates: &[Update], icons: &bool) { 14 | let sorted_updates = sort_update_vec(updates); 15 | let updates_by_server = { 16 | let mut servers: FxHashMap<&str, Vec<&Update>> = FxHashMap::default(); 17 | sorted_updates.iter().for_each(|update| { 18 | let key = update.server.as_deref().unwrap_or(""); 19 | match servers.get_mut(&key) { 20 | Some(server) => server.push(update), 21 | None => { 22 | let _ = servers.insert(key, vec![update]); 23 | } 24 | } 25 | }); 26 | servers 27 | }; 28 | for (server, updates) in updates_by_server { 29 | if server.is_empty() { 30 | println!("\x1b[90;1m~ Local images\x1b[0m") 31 | } else { 32 | println!("\x1b[90;1m~ {}\x1b[0m", server) 33 | } 34 | let (reference_width, status_width, time_width) = 35 | updates.iter().fold((9, 6, 9), |acc, update| { 36 | let reference_length = update.reference.len(); 37 | let status_length = update.get_status().to_string().len() 38 | + match &update.result.info { 39 | UpdateInfo::Version(info) => { 40 | info.current_version.len() + info.new_version.len() + 6 41 | } 42 | _ => 0, 43 | }; 44 | let time_length = update.time.to_string().len(); 45 | ( 46 | if reference_length > acc.0 { 47 | reference_length 48 | } else { 49 | acc.0 50 | }, 51 | if status_length > acc.1 { 52 | status_length 53 | } else { 54 | acc.1 55 | }, 56 | if time_length > acc.2 { 57 | time_length 58 | } else { 59 | acc.2 60 | }, 61 | ) 62 | }); 63 | println!( 64 | " \x1b[90;1m╭{:─<rw$}┬{:─<sw$}┬{:─<tw$}╮\x1b[0m", 65 | "", 66 | "", 67 | "", 68 | rw = reference_width, 69 | sw = status_width + { 70 | if *icons { 71 | 2 72 | } else { 73 | 0 74 | } 75 | }, 76 | tw = time_width 77 | ); 78 | println!( 79 | " \x1b[90;1m│\x1b[36;1m{:<rw$}\x1b[90;1m│\x1b[36;1m{:<sw$}\x1b[90;1m│\x1b[36;1m{:<tw$}\x1b[90;1m│\x1b[0m", 80 | "Reference", 81 | "Status", 82 | "Time (ms)", 83 | rw = reference_width, 84 | sw = status_width + { 85 | if *icons { 86 | 2 87 | } else { 88 | 0 89 | } 90 | }, 91 | tw = time_width 92 | ); 93 | println!( 94 | " \x1b[90;1m├{:─<rw$}┼{:─<sw$}┼{:─<tw$}┤\x1b[0m", 95 | "", 96 | "", 97 | "", 98 | rw = reference_width, 99 | sw = status_width + { 100 | if *icons { 101 | 2 102 | } else { 103 | 0 104 | } 105 | }, 106 | tw = time_width 107 | ); 108 | for update in updates { 109 | let status = update.get_status(); 110 | let icon = if *icons { 111 | match status { 112 | Status::UpToDate => "\u{f058} ", 113 | Status::Unknown(_) => "\u{f059} ", 114 | _ => "\u{f0aa} ", 115 | } 116 | } else { 117 | "" 118 | }; 119 | let color = match status { 120 | Status::UpdateAvailable | Status::UpdatePatch => "\x1b[34m", 121 | Status::UpdateMinor => "\x1b[33m", 122 | Status::UpdateMajor => "\x1b[31m", 123 | Status::UpToDate => "\x1b[32m", 124 | Status::Unknown(_) => "\x1b[90m", 125 | }; 126 | let description = format!( 127 | "{}{}", 128 | status, 129 | match &update.result.info { 130 | UpdateInfo::Version(info) => { 131 | format!(" ({} → {})", info.current_version, info.new_version) 132 | } 133 | _ => String::new(), 134 | } 135 | ); 136 | println!( 137 | " \x1b[90;1m│\x1b[0m{:<rw$}\x1b[90;1m│\x1b[0m{}{}{:<sw$}\x1b[0m\x1b[90;1m│\x1b[0m{:<tw$}\x1b[90;1m│\x1b[0m", 138 | update.reference, 139 | color, 140 | icon, 141 | description, 142 | update.time, 143 | rw = reference_width, 144 | sw = status_width, 145 | tw = time_width 146 | ); 147 | } 148 | println!( 149 | " \x1b[90;1m╰{:─<rw$}┴{:─<sw$}┴{:─<tw$}╯\x1b[0m", 150 | "", 151 | "", 152 | "", 153 | rw = reference_width, 154 | sw = status_width + { 155 | if *icons { 156 | 2 157 | } else { 158 | 0 159 | } 160 | }, 161 | tw = time_width 162 | ); 163 | } 164 | } 165 | 166 | pub fn print_raw_updates(updates: &[Update]) { 167 | println!("{}", to_simple_json(updates)); 168 | } 169 | -------------------------------------------------------------------------------- /src/formatting/spinner.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use indicatif::{ProgressBar, ProgressStyle}; 4 | 5 | pub struct Spinner { 6 | spinner: ProgressBar, 7 | } 8 | 9 | impl Spinner { 10 | #[allow(clippy::new_without_default)] 11 | pub fn new() -> Spinner { 12 | let spinner = ProgressBar::new_spinner(); 13 | let style: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 14 | let progress_style = ProgressStyle::default_spinner(); 15 | 16 | spinner.set_style(ProgressStyle::tick_strings(progress_style, style)); 17 | 18 | spinner.set_message("Checking..."); 19 | spinner.enable_steady_tick(Duration::from_millis(50)); 20 | 21 | Spinner { spinner } 22 | } 23 | pub fn succeed(&self) { 24 | const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m"; 25 | 26 | let success_message = format!("{} Done!", CHECKMARK); 27 | self.spinner 28 | .set_style(ProgressStyle::with_template("{msg}").unwrap()); 29 | self.spinner.finish_with_message(success_message); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/http.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use reqwest::Response; 4 | use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; 5 | use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; 6 | 7 | use crate::{error, Context}; 8 | 9 | pub enum RequestMethod { 10 | GET, 11 | HEAD, 12 | } 13 | 14 | impl Display for RequestMethod { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | f.write_str(match self { 17 | RequestMethod::GET => "GET", 18 | RequestMethod::HEAD => "HEAD", 19 | }) 20 | } 21 | } 22 | 23 | /// A struct for handling HTTP requests. Takes care of the repetitive work of checking for errors, etc and exposes a simple interface 24 | pub struct Client { 25 | inner: ClientWithMiddleware, 26 | ctx: Context, 27 | } 28 | 29 | impl Client { 30 | pub fn new(ctx: &Context) -> Self { 31 | Self { 32 | inner: ClientBuilder::new(reqwest::Client::new()) 33 | .with(RetryTransientMiddleware::new_with_policy( 34 | ExponentialBackoff::builder().build_with_max_retries(3), 35 | )) 36 | .build(), 37 | ctx: ctx.clone(), 38 | } 39 | } 40 | 41 | async fn request( 42 | &self, 43 | url: &str, 44 | method: RequestMethod, 45 | headers: &[(&str, Option<&str>)], 46 | ignore_401: bool, 47 | ) -> Result<Response, String> { 48 | let mut request = match method { 49 | RequestMethod::GET => self.inner.get(url), 50 | RequestMethod::HEAD => self.inner.head(url), 51 | }; 52 | for (name, value) in headers { 53 | if let Some(v) = value { 54 | request = request.header(*name, *v) 55 | } 56 | } 57 | match request.send().await { 58 | Ok(response) => { 59 | let status = response.status(); 60 | if status == 404 { 61 | let message = format!("{} {}: Not found!", method, url); 62 | self.ctx.logger.warn(&message); 63 | Err(message) 64 | } else if status == 401 { 65 | if ignore_401 { 66 | Ok(response) 67 | } else { 68 | let message = format!("{} {}: Unauthorized! Please configure authentication for this registry or if you have already done so, please make sure it is correct.", method, url); 69 | self.ctx.logger.warn(&message); 70 | Err(message) 71 | } 72 | } else if status == 502 { 73 | let message = format!("{} {}: The registry is currently unavailabile (returned status code 502).", method, url); 74 | self.ctx.logger.warn(&message); 75 | Err(message) 76 | } else if status.as_u16() <= 400 { 77 | Ok(response) 78 | } else { 79 | match method { 80 | RequestMethod::GET => error!( 81 | "{} {}: Unexpected error: {}", 82 | method, 83 | url, 84 | response.text().await.unwrap() 85 | ), 86 | RequestMethod::HEAD => error!( 87 | "{} {}: Unexpected error: Recieved status code {}", 88 | method, url, status 89 | ), 90 | } 91 | } 92 | } 93 | Err(error) => { 94 | if error.is_connect() { 95 | let message = format!("{} {}: Connection failed!", method, url); 96 | self.ctx.logger.warn(&message); 97 | Err(message) 98 | } else if error.is_timeout() { 99 | let message = format!("{} {}: Connection timed out!", method, url); 100 | self.ctx.logger.warn(&message); 101 | Err(message) 102 | } else if error.is_middleware() { 103 | let message = format!("{} {}: Connection failed after 3 retries!", method, url); 104 | self.ctx.logger.warn(&message); 105 | Err(message) 106 | } else { 107 | error!( 108 | "{} {}: Unexpected error: {}", 109 | method, 110 | url, 111 | error.to_string() 112 | ) 113 | } 114 | } 115 | } 116 | } 117 | 118 | pub async fn get( 119 | &self, 120 | url: &str, 121 | headers: &[(&str, Option<&str>)], 122 | ignore_401: bool, 123 | ) -> Result<Response, String> { 124 | self.request(url, RequestMethod::GET, headers, ignore_401) 125 | .await 126 | } 127 | 128 | pub async fn head( 129 | &self, 130 | url: &str, 131 | headers: &[(&str, Option<&str>)], 132 | ) -> Result<Response, String> { 133 | self.request(url, RequestMethod::HEAD, headers, false).await 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! error { 3 | ($($arg:tt)*) => ({ 4 | eprintln!("\x1b[31;1mERROR\x1b[0m {}", format!($($arg)*)); 5 | std::process::exit(1); 6 | }) 7 | } 8 | 9 | /// This struct mostly exists so we can print stuff without passing debug or raw every time. 10 | #[derive(Clone)] 11 | pub struct Logger { 12 | debug: bool, 13 | raw: bool, 14 | } 15 | 16 | impl Logger { 17 | pub fn new(debug: bool, raw: bool) -> Self { 18 | Self { debug, raw } 19 | } 20 | 21 | pub fn warn(&self, msg: impl AsRef<str>) { 22 | if !self.raw { 23 | eprintln!("\x1b[33;1m WARN\x1b[0m {}", msg.as_ref()); 24 | } 25 | } 26 | 27 | pub fn info(&self, msg: impl AsRef<str>) { 28 | if !self.raw { 29 | println!("\x1b[36;1m INFO\x1b[0m {}", msg.as_ref()); 30 | } 31 | } 32 | 33 | pub fn debug(&self, msg: impl AsRef<str>) { 34 | if self.debug { 35 | println!("\x1b[35;1mDEBUG\x1b[0m {}", msg.as_ref()); 36 | } 37 | } 38 | 39 | pub fn set_raw(&mut self, raw: bool) { 40 | self.raw = raw 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use check::get_updates; 2 | use clap::{Parser, Subcommand}; 3 | use config::Config; 4 | use formatting::spinner::Spinner; 5 | #[cfg(feature = "cli")] 6 | use formatting::{print_raw_updates, print_updates}; 7 | use logging::Logger; 8 | #[cfg(feature = "server")] 9 | use server::serve; 10 | use std::path::PathBuf; 11 | use std::time::SystemTime; 12 | 13 | pub mod check; 14 | pub mod config; 15 | pub mod docker; 16 | #[cfg(feature = "cli")] 17 | pub mod formatting; 18 | pub mod http; 19 | pub mod logging; 20 | pub mod registry; 21 | #[cfg(feature = "server")] 22 | pub mod server; 23 | pub mod structs; 24 | pub mod utils; 25 | 26 | #[derive(Parser)] 27 | #[command(version, about, long_about = None)] 28 | struct Cli { 29 | #[arg(short, long, default_value = None)] 30 | socket: Option<String>, 31 | #[arg(short, long, default_value_t = String::new(), help = "Config file path")] 32 | config_path: String, 33 | #[command(subcommand)] 34 | command: Option<Commands>, 35 | #[arg(short, long)] 36 | debug: bool, 37 | #[arg(long)] 38 | refresh: bool, 39 | } 40 | 41 | #[derive(Subcommand)] 42 | enum Commands { 43 | #[cfg(feature = "cli")] 44 | Check { 45 | #[arg(name = "images", default_value = None)] 46 | references: Option<Vec<String>>, 47 | #[arg(short, long, default_value_t = false, help = "Enable icons")] 48 | icons: bool, 49 | #[arg( 50 | short, 51 | long, 52 | default_value_t = false, 53 | help = "Output JSON instead of formatted text" 54 | )] 55 | raw: bool, 56 | }, 57 | #[cfg(feature = "server")] 58 | Serve { 59 | #[arg( 60 | short, 61 | long, 62 | default_value_t = 8000, 63 | help = "Use a different port for the server" 64 | )] 65 | port: u16, 66 | }, 67 | } 68 | 69 | #[derive(Clone)] 70 | pub struct Context { 71 | pub config: Config, 72 | pub logger: Logger, 73 | } 74 | 75 | #[tokio::main] 76 | async fn main() { 77 | let cli = Cli::parse(); 78 | let cfg_path = match cli.config_path.as_str() { 79 | "" => None, 80 | path => Some(PathBuf::from(path)), 81 | }; 82 | let mut config = Config::new().load(cfg_path); 83 | if let Some(socket) = cli.socket { 84 | config.socket = Some(socket) 85 | } 86 | let mut ctx = Context { 87 | config, 88 | logger: Logger::new(cli.debug, false), 89 | }; 90 | match &cli.command { 91 | #[cfg(feature = "cli")] 92 | Some(Commands::Check { 93 | references, 94 | icons, 95 | raw, 96 | }) => { 97 | let start = SystemTime::now(); 98 | if *raw { 99 | ctx.logger.set_raw(true); 100 | } 101 | match *raw || cli.debug { 102 | true => { 103 | let updates = get_updates(references, cli.refresh, &ctx).await; 104 | print_raw_updates(&updates); 105 | } 106 | false => { 107 | let spinner = Spinner::new(); 108 | let updates = get_updates(references, cli.refresh, &ctx).await; 109 | spinner.succeed(); 110 | print_updates(&updates, icons); 111 | ctx.logger.info(format!("✨ Checked {} images in {}ms", updates.len(), start.elapsed().unwrap().as_millis())); 112 | } 113 | }; 114 | } 115 | #[cfg(feature = "server")] 116 | Some(Commands::Serve { port }) => { 117 | let _ = serve(port, &ctx).await; 118 | } 119 | None => error!("Whoops! It looks like you haven't specified a command to run! Try `cup help` to see available options."), 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/structs/inspectdata.rs: -------------------------------------------------------------------------------- 1 | use bollard::secret::{ImageInspect, ImageSummary}; 2 | 3 | pub trait InspectData { 4 | fn tags(&self) -> Option<Vec<String>>; 5 | fn digests(&self) -> Option<Vec<String>>; 6 | fn url(&self) -> Option<String>; 7 | } 8 | 9 | impl InspectData for ImageInspect { 10 | fn tags(&self) -> Option<Vec<String>> { 11 | self.repo_tags.clone() 12 | } 13 | 14 | fn digests(&self) -> Option<Vec<String>> { 15 | self.repo_digests.clone() 16 | } 17 | 18 | fn url(&self) -> Option<String> { 19 | match &self.config { 20 | Some(config) => match &config.labels { 21 | Some(labels) => labels.get("org.opencontainers.image.url").cloned(), 22 | None => None, 23 | }, 24 | None => None, 25 | } 26 | } 27 | } 28 | 29 | impl InspectData for ImageSummary { 30 | fn tags(&self) -> Option<Vec<String>> { 31 | Some(self.repo_tags.clone()) 32 | } 33 | 34 | fn digests(&self) -> Option<Vec<String>> { 35 | Some(self.repo_digests.clone()) 36 | } 37 | 38 | fn url(&self) -> Option<String> { 39 | self.labels.get("org.opencontainers.image.url").cloned() 40 | } 41 | } 42 | 43 | impl InspectData for &String { 44 | fn tags(&self) -> Option<Vec<String>> { 45 | self.split('@').next().map(|tag| vec![tag.to_string()]) 46 | } 47 | 48 | fn digests(&self) -> Option<Vec<String>> { 49 | match self.split_once('@') { 50 | Some((reference, digest)) => Some(vec![format!( 51 | "{}@{}", 52 | reference.split(':').next().unwrap(), 53 | digest 54 | )]), 55 | None => Some(vec![]), 56 | } 57 | } 58 | 59 | fn url(&self) -> Option<String> { 60 | None 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/structs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod image; 2 | pub mod inspectdata; 3 | pub mod parts; 4 | pub mod status; 5 | pub mod update; 6 | pub mod version; 7 | -------------------------------------------------------------------------------- /src/structs/parts.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] 4 | pub struct Parts { 5 | pub registry: String, 6 | pub repository: String, 7 | pub tag: String, 8 | } 9 | -------------------------------------------------------------------------------- /src/structs/status.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | /// Enum for image status 4 | #[derive(Ord, Eq, PartialEq, PartialOrd, Clone, Debug)] 5 | pub enum Status { 6 | UpdateMajor, 7 | UpdateMinor, 8 | UpdatePatch, 9 | UpdateAvailable, 10 | UpToDate, 11 | Unknown(String), 12 | } 13 | 14 | impl Display for Status { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | f.write_str(match &self { 17 | Self::UpToDate => "Up to date", 18 | Self::UpdateAvailable => "Update available", 19 | Self::UpdateMajor => "Major update", 20 | Self::UpdateMinor => "Minor update", 21 | Self::UpdatePatch => "Patch update", 22 | Self::Unknown(_) => "Unknown", 23 | }) 24 | } 25 | } 26 | 27 | impl Status { 28 | // Converts the Status into an Option<bool> (useful for JSON serialization) 29 | pub fn to_option_bool(&self) -> Option<bool> { 30 | match &self { 31 | Self::UpToDate => Some(false), 32 | Self::Unknown(_) => None, 33 | _ => Some(true), 34 | } 35 | } 36 | } 37 | 38 | impl Default for Status { 39 | fn default() -> Self { 40 | Self::Unknown("".to_string()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/structs/update.rs: -------------------------------------------------------------------------------- 1 | use serde::{ser::SerializeStruct, Deserialize, Serialize}; 2 | 3 | use super::{parts::Parts, status::Status}; 4 | 5 | #[derive(Serialize, Deserialize, Clone, Debug)] 6 | #[cfg_attr(test, derive(PartialEq, Default))] 7 | pub struct Update { 8 | pub reference: String, 9 | pub parts: Parts, 10 | pub url: Option<String>, 11 | pub result: UpdateResult, 12 | pub time: u32, 13 | pub server: Option<String>, 14 | pub in_use: bool, 15 | #[serde(skip_serializing, skip_deserializing)] 16 | pub status: Status, 17 | } 18 | 19 | #[derive(Serialize, Deserialize, Clone, Debug)] 20 | #[cfg_attr(test, derive(PartialEq, Default))] 21 | pub struct UpdateResult { 22 | pub has_update: Option<bool>, 23 | pub info: UpdateInfo, 24 | pub error: Option<String>, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, Clone, Debug)] 28 | #[cfg_attr(test, derive(PartialEq, Default))] 29 | #[serde(untagged)] 30 | pub enum UpdateInfo { 31 | #[cfg_attr(test, default)] 32 | None, 33 | Version(VersionUpdateInfo), 34 | Digest(DigestUpdateInfo), 35 | } 36 | 37 | #[derive(Deserialize, Clone, Debug)] 38 | #[cfg_attr(test, derive(PartialEq))] 39 | pub struct VersionUpdateInfo { 40 | pub version_update_type: String, 41 | pub new_tag: String, 42 | pub current_version: String, 43 | pub new_version: String, 44 | } 45 | 46 | #[derive(Deserialize, Clone, Debug)] 47 | #[cfg_attr(test, derive(PartialEq))] 48 | pub struct DigestUpdateInfo { 49 | pub local_digests: Vec<String>, 50 | pub remote_digest: Option<String>, 51 | } 52 | 53 | impl Serialize for VersionUpdateInfo { 54 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 55 | where 56 | S: serde::Serializer, 57 | { 58 | let mut state = serializer.serialize_struct("VersionUpdateInfo", 5)?; 59 | let _ = state.serialize_field("type", "version"); 60 | let _ = state.serialize_field("version_update_type", &self.version_update_type); 61 | let _ = state.serialize_field("new_tag", &self.new_tag); 62 | let _ = state.serialize_field("current_version", &self.current_version); 63 | let _ = state.serialize_field("new_version", &self.new_version); 64 | state.end() 65 | } 66 | } 67 | 68 | impl Serialize for DigestUpdateInfo { 69 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 70 | where 71 | S: serde::Serializer, 72 | { 73 | let mut state = serializer.serialize_struct("DigestUpdateInfo", 3)?; 74 | let _ = state.serialize_field("type", "digest"); 75 | let _ = state.serialize_field("local_digests", &self.local_digests); 76 | let _ = state.serialize_field("remote_digest", &self.remote_digest); 77 | state.end() 78 | } 79 | } 80 | 81 | impl Update { 82 | pub fn get_status(&self) -> Status { 83 | match &self.status { 84 | Status::Unknown(s) => { 85 | if s.is_empty() { 86 | match self.result.has_update { 87 | Some(true) => match &self.result.info { 88 | UpdateInfo::Version(info) => match info.version_update_type.as_str() { 89 | "major" => Status::UpdateMajor, 90 | "minor" => Status::UpdateMinor, 91 | "patch" => Status::UpdatePatch, 92 | _ => unreachable!(), 93 | }, 94 | UpdateInfo::Digest(_) => Status::UpdateAvailable, 95 | _ => unreachable!(), 96 | }, 97 | Some(false) => Status::UpToDate, 98 | None => Status::Unknown(self.result.error.clone().unwrap()), 99 | } 100 | } else { 101 | self.status.clone() 102 | } 103 | } 104 | status => status.clone(), 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/structs/version.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::Ordering, fmt::Display}; 2 | 3 | use once_cell::sync::Lazy; 4 | use regex::Regex; 5 | 6 | use super::status::Status; 7 | 8 | /// Semver-like version struct 9 | #[derive(Debug, PartialEq, Eq, Clone)] 10 | pub struct Version { 11 | pub major: u32, 12 | pub minor: Option<u32>, 13 | pub patch: Option<u32>, 14 | } 15 | 16 | impl Version { 17 | /// Tries to parse the tag into semver-like parts. Returns a Version object and a string usable in format! with {} in the positions matches were found 18 | pub fn from_tag(tag: &str) -> Option<(Self, String)> { 19 | /// Heavily modified version of the official semver regex based on common tagging schemes for container images. Sometimes it matches more than once, but we'll try to select the best match. 20 | static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| { 21 | Regex::new( 22 | r"(?P<major>0|[1-9][0-9]*)(?:\.(?P<minor>0|[1-9][0-9]*))?(?:\.(?P<patch>0|[1-9][0-9]*))?", 23 | ) 24 | .unwrap() 25 | }); 26 | let captures = VERSION_REGEX.captures_iter(tag); 27 | // And now... terrible best match selection for everyone! 28 | let mut max_matches = 0; 29 | let mut best_match = None; 30 | for capture in captures { 31 | let mut count = 0; 32 | for idx in 1..capture.len() { 33 | if capture.get(idx).is_some() { 34 | count += 1 35 | } else { 36 | break; 37 | } 38 | } 39 | if count > max_matches { 40 | max_matches = count; 41 | best_match = Some(capture); 42 | } 43 | } 44 | match best_match { 45 | Some(c) => { 46 | let mut positions = Vec::new(); 47 | let major: u32 = match c.name("major") { 48 | Some(major) => { 49 | positions.push((major.start(), major.end())); 50 | match major.as_str().parse() { 51 | Ok(m) => m, 52 | Err(_) => return None, 53 | } 54 | } 55 | None => return None, 56 | }; 57 | let minor: Option<u32> = c.name("minor").map(|minor| { 58 | positions.push((minor.start(), minor.end())); 59 | minor 60 | .as_str() 61 | .parse() 62 | .unwrap_or_else(|_| panic!("Minor version invalid in tag {}", tag)) 63 | }); 64 | let patch: Option<u32> = c.name("patch").map(|patch| { 65 | positions.push((patch.start(), patch.end())); 66 | patch 67 | .as_str() 68 | .parse() 69 | .unwrap_or_else(|_| panic!("Patch version invalid in tag {}", tag)) 70 | }); 71 | let mut format_str = tag.to_string(); 72 | positions.reverse(); 73 | positions.iter().for_each(|(start, end)| { 74 | format_str.replace_range(*start..*end, "{}"); 75 | }); 76 | Some(( 77 | Version { 78 | major, 79 | minor, 80 | patch, 81 | }, 82 | format_str, 83 | )) 84 | } 85 | None => None, 86 | } 87 | } 88 | 89 | pub fn to_status(&self, base: &Self) -> Status { 90 | match self.major.cmp(&base.major) { 91 | Ordering::Greater => Status::UpdateMajor, 92 | Ordering::Equal => match (self.minor, base.minor) { 93 | (Some(a_minor), Some(b_minor)) => match a_minor.cmp(&b_minor) { 94 | Ordering::Greater => Status::UpdateMinor, 95 | Ordering::Equal => match (self.patch, base.patch) { 96 | (Some(a_patch), Some(b_patch)) => match a_patch.cmp(&b_patch) { 97 | Ordering::Greater => Status::UpdatePatch, 98 | Ordering::Equal => Status::UpToDate, 99 | Ordering::Less => { 100 | Status::Unknown(format!("Tag {} does not exist", base)) 101 | } 102 | }, 103 | (None, None) => Status::UpToDate, 104 | _ => unreachable!(), 105 | }, 106 | Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)), 107 | }, 108 | (None, None) => Status::UpToDate, 109 | _ => unreachable!( 110 | "Version error: {} and {} should either both be Some or None", 111 | self, base 112 | ), 113 | }, 114 | Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)), 115 | } 116 | } 117 | } 118 | 119 | impl Ord for Version { 120 | fn cmp(&self, other: &Self) -> Ordering { 121 | let major_ordering = self.major.cmp(&other.major); 122 | match major_ordering { 123 | Ordering::Equal => match (self.minor, other.minor) { 124 | (Some(self_minor), Some(other_minor)) => { 125 | let minor_ordering = self_minor.cmp(&other_minor); 126 | match minor_ordering { 127 | Ordering::Equal => match (self.patch, other.patch) { 128 | (Some(self_patch), Some(other_patch)) => self_patch.cmp(&other_patch), 129 | _ => Ordering::Equal, 130 | }, 131 | _ => minor_ordering, 132 | } 133 | } 134 | _ => Ordering::Equal, 135 | }, 136 | _ => major_ordering, 137 | } 138 | } 139 | } 140 | 141 | impl PartialOrd for Version { 142 | fn partial_cmp(&self, other: &Self) -> Option<Ordering> { 143 | Some(self.cmp(other)) 144 | } 145 | } 146 | 147 | impl Display for Version { 148 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 149 | f.write_str(&format!( 150 | "{}{}{}", 151 | self.major, 152 | match self.minor { 153 | Some(minor) => format!(".{}", minor), 154 | None => String::new(), 155 | }, 156 | match self.patch { 157 | Some(patch) => format!(".{}", patch), 158 | None => String::new(), 159 | } 160 | )) 161 | } 162 | } 163 | 164 | #[cfg(test)] 165 | mod tests { 166 | use super::*; 167 | 168 | #[test] 169 | #[rustfmt::skip] 170 | fn version() { 171 | assert_eq!(Version::from_tag("5.3.2" ), Some((Version { major: 5, minor: Some(3), patch: Some(2) }, String::from("{}.{}.{}" )))); 172 | assert_eq!(Version::from_tag("14" ), Some((Version { major: 14, minor: None, patch: None }, String::from("{}" )))); 173 | assert_eq!(Version::from_tag("v0.107.53" ), Some((Version { major: 0, minor: Some(107), patch: Some(53) }, String::from("v{}.{}.{}" )))); 174 | assert_eq!(Version::from_tag("12-alpine" ), Some((Version { major: 12, minor: None, patch: None }, String::from("{}-alpine" )))); 175 | assert_eq!(Version::from_tag("0.9.5-nginx" ), Some((Version { major: 0, minor: Some(9), patch: Some(5) }, String::from("{}.{}.{}-nginx" )))); 176 | assert_eq!(Version::from_tag("v27.0" ), Some((Version { major: 27, minor: Some(0), patch: None }, String::from("v{}.{}" )))); 177 | assert_eq!(Version::from_tag("16.1" ), Some((Version { major: 16, minor: Some(1), patch: None }, String::from("{}.{}" )))); 178 | assert_eq!(Version::from_tag("version-1.5.6" ), Some((Version { major: 1, minor: Some(5), patch: Some(6) }, String::from("version-{}.{}.{}" )))); 179 | assert_eq!(Version::from_tag("15.4-alpine" ), Some((Version { major: 15, minor: Some(4), patch: None }, String::from("{}.{}-alpine" )))); 180 | assert_eq!(Version::from_tag("pg14-v0.2.0" ), Some((Version { major: 0, minor: Some(2), patch: Some(0) }, String::from("pg14-v{}.{}.{}" )))); 181 | assert_eq!(Version::from_tag("18-jammy-full.s6-v0.88.0"), Some((Version { major: 0, minor: Some(88), patch: Some(0) }, String::from("18-jammy-full.s6-v{}.{}.{}")))); 182 | assert_eq!(Version::from_tag("fpm-2.1.0-prod" ), Some((Version { major: 2, minor: Some(1), patch: Some(0) }, String::from("fpm-{}.{}.{}-prod" )))); 183 | assert_eq!(Version::from_tag("7.3.3.50" ), Some((Version { major: 7, minor: Some(3), patch: Some(3) }, String::from("{}.{}.{}.50" )))); 184 | assert_eq!(Version::from_tag("1.21.11-0" ), Some((Version { major: 1, minor: Some(21), patch: Some(11) }, String::from("{}.{}.{}-0" )))); 185 | assert_eq!(Version::from_tag("4.1.2.1-full" ), Some((Version { major: 4, minor: Some(1), patch: Some(2) }, String::from("{}.{}.{}.1-full" )))); 186 | assert_eq!(Version::from_tag("v4.0.3-ls215" ), Some((Version { major: 4, minor: Some(0), patch: Some(3) }, String::from("v{}.{}.{}-ls215" )))); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/utils/json.rs: -------------------------------------------------------------------------------- 1 | // Functions that return JSON data, used for generating output and API responses 2 | 3 | use serde_json::{json, Map, Value}; 4 | 5 | use crate::structs::{status::Status, update::Update}; 6 | 7 | /// Helper function to get metrics used in JSON output 8 | pub fn get_metrics(updates: &[Update]) -> Value { 9 | let mut up_to_date = 0; 10 | let mut major_updates = 0; 11 | let mut minor_updates = 0; 12 | let mut patch_updates = 0; 13 | let mut other_updates = 0; 14 | let mut unknown = 0; 15 | updates.iter().for_each(|image| { 16 | let has_update = image.get_status(); 17 | match has_update { 18 | Status::UpdateMajor => { 19 | major_updates += 1; 20 | } 21 | Status::UpdateMinor => { 22 | minor_updates += 1; 23 | } 24 | Status::UpdatePatch => { 25 | patch_updates += 1; 26 | } 27 | Status::UpdateAvailable => { 28 | other_updates += 1; 29 | } 30 | Status::UpToDate => { 31 | up_to_date += 1; 32 | } 33 | Status::Unknown(_) => { 34 | unknown += 1; 35 | } 36 | }; 37 | }); 38 | json!({ 39 | "monitored_images": updates.len(), 40 | "updates_available": major_updates + minor_updates + patch_updates + other_updates, 41 | "major_updates": major_updates, 42 | "minor_updates": minor_updates, 43 | "patch_updates": patch_updates, 44 | "other_updates": other_updates, 45 | "up_to_date": up_to_date, 46 | "unknown": unknown 47 | }) 48 | } 49 | 50 | /// Takes a slice of `Image` objects and returns a `Value` with update info. The output doesn't contain much detail 51 | pub fn to_simple_json(updates: &[Update]) -> Value { 52 | let mut update_map = Map::new(); 53 | updates.iter().for_each(|update| { 54 | let _ = update_map.insert( 55 | update.reference.clone(), 56 | match update.result.has_update { 57 | Some(has_update) => Value::Bool(has_update), 58 | None => Value::Null, 59 | }, 60 | ); 61 | }); 62 | let json_data: Value = json!({ 63 | "metrics": get_metrics(updates), 64 | "images": updates, 65 | }); 66 | json_data 67 | } 68 | 69 | /// Takes a slice of `Image` objects and returns a `Value` with update info. All image data is included, useful for debugging. 70 | pub fn to_full_json(updates: &[Update]) -> Value { 71 | json!({ 72 | "metrics": get_metrics(updates), 73 | "images": updates.iter().map(|update| serde_json::to_value(update).unwrap()).collect::<Vec<Value>>(), 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/link.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use http_link::parse_link_header; 4 | use reqwest::Url; 5 | 6 | use crate::error; 7 | 8 | pub fn parse_link(link: &str, base: &str) -> String { 9 | match parse_link_header(link, &Url::from_str(base).unwrap()) { 10 | Ok(l) => l[0].target.to_string(), 11 | Err(e) => error!("Failed to parse link! {}", e), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod json; 2 | pub mod link; 3 | pub mod reference; 4 | pub mod request; 5 | pub mod sort_update_vec; 6 | pub mod time; 7 | -------------------------------------------------------------------------------- /src/utils/reference.rs: -------------------------------------------------------------------------------- 1 | const DEFAULT_REGISTRY: &str = "registry-1.docker.io"; 2 | 3 | /// Takes an image and splits it into registry, repository and tag, based on the reference. 4 | /// For example, `ghcr.io/sergi0g/cup:latest` becomes `['ghcr.io', 'sergi0g/cup', 'latest']`. 5 | pub fn split(reference: &str) -> (String, String, String) { 6 | let splits = reference.split('/').collect::<Vec<&str>>(); 7 | let (registry, repository_and_tag) = match splits.len() { 8 | 0 => unreachable!(), 9 | 1 => (DEFAULT_REGISTRY, reference.to_string()), 10 | _ => { 11 | // Check if the image is from Docker Hub 12 | if splits[0] == "docker.io" { 13 | (DEFAULT_REGISTRY, splits[1..].join("/")) 14 | // Check if we're looking at a domain 15 | } else if splits[0] == "localhost" || splits[0].contains('.') || splits[0].contains(':') 16 | { 17 | (splits[0], splits[1..].join("/")) 18 | } else { 19 | (DEFAULT_REGISTRY, reference.to_string()) 20 | } 21 | } 22 | }; 23 | let splits = repository_and_tag 24 | .split('@') 25 | .next() 26 | .unwrap() 27 | .split(':') 28 | .collect::<Vec<&str>>(); 29 | let (repository, tag) = match splits.len() { 30 | 1 | 2 => { 31 | let repository_components = splits[0].split('/').collect::<Vec<&str>>(); 32 | let repository = match repository_components.len() { 33 | 0 => unreachable!(), 34 | 1 => { 35 | if registry == DEFAULT_REGISTRY { 36 | format!("library/{}", repository_components[0]) 37 | } else { 38 | splits[0].to_string() 39 | } 40 | } 41 | _ => splits[0].to_string(), 42 | }; 43 | let tag = match splits.len() { 44 | 1 => "latest", 45 | 2 => splits[1], 46 | _ => unreachable!(), 47 | }; 48 | (repository, tag) 49 | } 50 | _ => { 51 | panic!("Failed to parse reference! Splits: {:?}", splits) 52 | } 53 | }; 54 | (registry.to_string(), repository, tag.to_string()) 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use super::*; 60 | 61 | #[test] 62 | #[rustfmt::skip] 63 | fn reference() { 64 | assert_eq!(split("alpine" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest"))); 65 | assert_eq!(split("alpine:latest" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest"))); 66 | assert_eq!(split("library/alpine" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest"))); 67 | assert_eq!(split("localhost/test" ), (String::from("localhost" ), String::from("test" ), String::from("latest"))); 68 | assert_eq!(split("localhost:1234/test" ), (String::from("localhost:1234" ), String::from("test" ), String::from("latest"))); 69 | assert_eq!(split("test:1234/idk" ), (String::from("test:1234" ), String::from("idk" ), String::from("latest"))); 70 | assert_eq!(split("alpine:3.7" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("3.7" ))); 71 | assert_eq!(split("docker.io/library/alpine" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest"))); 72 | assert_eq!(split("docker.example.com/examplerepo/alpine:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine" ), String::from("3.7" ))); 73 | 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" ))); 74 | 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" ))); 75 | assert_eq!(split("docker.example.com:5000/examplerepo/alpine:latest" ), (String::from("docker.example.com:5000"), String::from("examplerepo/alpine" ), String::from("latest"))); 76 | assert_eq!(split("portainer/portainer:latest" ), (String::from(DEFAULT_REGISTRY ), String::from("portainer/portainer" ), String::from("latest"))); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/request.rs: -------------------------------------------------------------------------------- 1 | use http_auth::parse_challenges; 2 | use reqwest::Response; 3 | use rustc_hash::FxHashMap; 4 | use serde_json::Value; 5 | 6 | use crate::{config::RegistryConfig, error}; 7 | 8 | /// Parses the www-authenticate header the registry sends into a challenge URL 9 | pub fn parse_www_authenticate(www_auth: &str) -> String { 10 | let challenges = parse_challenges(www_auth).unwrap(); 11 | if !challenges.is_empty() { 12 | let challenge = &challenges[0]; 13 | if challenge.scheme == "Bearer" { 14 | challenge 15 | .params 16 | .iter() 17 | .fold(String::new(), |acc, (key, value)| { 18 | if *key == "realm" { 19 | acc.to_owned() + value.as_escaped() + "?" 20 | } else if value.unescaped_len() != 0 { 21 | format!("{}&{}={}", acc, key, value.as_escaped()) 22 | } else { 23 | acc 24 | } 25 | }) 26 | } else { 27 | error!("Unsupported scheme {}", &challenge.scheme) 28 | } 29 | } else { 30 | error!("No challenge provided by the server"); 31 | } 32 | } 33 | 34 | pub fn get_protocol( 35 | registry: &str, 36 | registry_config: &FxHashMap<String, RegistryConfig>, 37 | ) -> &'static str { 38 | match registry_config.get(registry) { 39 | Some(config) => { 40 | if config.insecure { 41 | "http" 42 | } else { 43 | "https" 44 | } 45 | } 46 | None => "https", 47 | } 48 | } 49 | 50 | pub fn to_bearer_string(token: &Option<&str>) -> Option<String> { 51 | token.as_ref().map(|t| format!("Bearer {}", t)) 52 | } 53 | 54 | pub async fn get_response_body(response: Response) -> String { 55 | match response.text().await { 56 | Ok(res) => res, 57 | Err(e) => { 58 | error!("Failed to parse registry response into string!\n{}", e) 59 | } 60 | } 61 | } 62 | 63 | pub fn parse_json(body: &str) -> Value { 64 | match serde_json::from_str(body) { 65 | Ok(parsed) => parsed, 66 | Err(e) => { 67 | error!("Failed to parse server response\n{}", e) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/sort_update_vec.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use crate::structs::update::Update; 4 | 5 | /// Sorts the update vector alphabetically and by Status 6 | pub fn sort_update_vec(updates: &[Update]) -> Vec<Update> { 7 | let mut sorted_updates = updates.to_vec(); 8 | sorted_updates.sort_by(|a, b| { 9 | let cmp = a.get_status().cmp(&b.get_status()); 10 | if cmp == Ordering::Equal { 11 | a.reference.cmp(&b.reference) 12 | } else { 13 | cmp 14 | } 15 | }); 16 | sorted_updates.to_vec() 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use crate::structs::{status::Status, update::UpdateResult}; 22 | 23 | use super::*; 24 | 25 | /// Test the `sort_update_vec` function 26 | /// We test for sorting based on status (Major > Minor > Patch > Digest > Up to date > Unknown) and that references are sorted alphabetically. 27 | #[test] 28 | fn test_ordering() { 29 | // Create test objects 30 | let major_update_1 = create_major_update("redis:6.2"); // We're ignoring the tag we passed here, that is tested in version.rs 31 | let major_update_2 = create_major_update("traefik:v3.0"); 32 | let minor_update_1 = create_minor_update("mysql:8.0"); 33 | let minor_update_2 = create_minor_update("rust:1.80.1-alpine"); 34 | let patch_update_1 = create_patch_update("node:20"); 35 | let patch_update_2 = create_patch_update("valkey/valkey:7.2-alpine"); 36 | let digest_update_1 = create_digest_update("busybox"); 37 | let digest_update_2 = create_digest_update("library/alpine"); 38 | let up_to_date_1 = create_up_to_date("docker:dind"); 39 | let up_to_date_2 = create_up_to_date("ghcr.io/sergi0g/cup"); 40 | let unknown_1 = create_unknown("fake_registry.com/fake/Update"); 41 | let unknown_2 = create_unknown("private_registry.io/private/Update"); 42 | let input_vec = vec![ 43 | major_update_2.clone(), 44 | unknown_2.clone(), 45 | minor_update_2.clone(), 46 | patch_update_2.clone(), 47 | up_to_date_1.clone(), 48 | unknown_1.clone(), 49 | patch_update_1.clone(), 50 | digest_update_2.clone(), 51 | minor_update_1.clone(), 52 | major_update_1.clone(), 53 | digest_update_1.clone(), 54 | up_to_date_2.clone(), 55 | ]; 56 | let expected_vec = vec![ 57 | major_update_1, 58 | major_update_2, 59 | minor_update_1, 60 | minor_update_2, 61 | patch_update_1, 62 | patch_update_2, 63 | digest_update_1, 64 | digest_update_2, 65 | up_to_date_1, 66 | up_to_date_2, 67 | unknown_1, 68 | unknown_2, 69 | ]; 70 | 71 | // Sort the vec 72 | let sorted_vec = sort_update_vec(&input_vec); 73 | 74 | // Check results 75 | assert_eq!(sorted_vec, expected_vec); 76 | } 77 | 78 | fn create_unknown(reference: &str) -> Update { 79 | Update { 80 | reference: reference.to_string(), 81 | status: Status::Unknown("".to_string()), 82 | result: UpdateResult { 83 | has_update: None, 84 | info: Default::default(), 85 | error: Some("Error".to_string()), 86 | }, 87 | ..Default::default() 88 | } 89 | } 90 | 91 | fn create_up_to_date(reference: &str) -> Update { 92 | Update { 93 | reference: reference.to_string(), 94 | status: Status::UpToDate, 95 | ..Default::default() 96 | } 97 | } 98 | 99 | fn create_digest_update(reference: &str) -> Update { 100 | Update { 101 | reference: reference.to_string(), 102 | status: Status::UpdateAvailable, 103 | ..Default::default() 104 | } 105 | } 106 | 107 | fn create_patch_update(reference: &str) -> Update { 108 | Update { 109 | reference: reference.to_string(), 110 | status: Status::UpdatePatch, 111 | ..Default::default() 112 | } 113 | } 114 | 115 | fn create_minor_update(reference: &str) -> Update { 116 | Update { 117 | reference: reference.to_string(), 118 | status: Status::UpdateMinor, 119 | ..Default::default() 120 | } 121 | } 122 | 123 | fn create_major_update(reference: &str) -> Update { 124 | Update { 125 | reference: reference.to_string(), 126 | status: Status::UpdateMajor, 127 | ..Default::default() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/utils/time.rs: -------------------------------------------------------------------------------- 1 | // When you're too bored to type some things, you get this... 2 | 3 | use std::time::SystemTime; 4 | 5 | pub fn elapsed(start: SystemTime) -> u32 { 6 | start.elapsed().unwrap().as_millis() as u32 7 | } 8 | 9 | pub fn now() -> SystemTime { 10 | SystemTime::now() 11 | } 12 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | tsconfig.*.tsbuildinfo 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | index.html -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Cup web frontend 2 | 3 | This is the Cup web frontend, built with Vite and React. Once it's built, Cup modifies a few things (notably the theme) and sends the result to the client. 4 | 5 | # Development 6 | 7 | Requirements: Bun, Node.js 20+ 8 | 9 | Install dependencies with `bun install` and start the development server with `bun dev`. 10 | -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [ 11 | js.configs.recommended, 12 | ...tseslint.configs.strictTypeChecked, 13 | ...tseslint.configs.stylisticTypeChecked, 14 | ], 15 | files: ["**/*.{ts,tsx}"], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | project: ["./tsconfig.node.json", "./tsconfig.app.json"], 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | plugins: { 25 | "react-hooks": reactHooks, 26 | "react-refresh": reactRefresh, 27 | }, 28 | rules: { 29 | ...reactHooks.configs.recommended.rules, 30 | "react-refresh/only-export-components": [ 31 | "warn", 32 | { allowConstantExport: true }, 33 | ], 34 | }, 35 | }, 36 | ); 37 | -------------------------------------------------------------------------------- /web/index.liquid: -------------------------------------------------------------------------------- 1 | index.html -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "fmt": "prettier --write ." 12 | }, 13 | "dependencies": { 14 | "@headlessui/react": "^2.1.10", 15 | "@radix-ui/react-checkbox": "^1.2.3", 16 | "@radix-ui/react-tooltip": "^1.1.2", 17 | "clsx": "^2.1.1", 18 | "date-fns": "^3.6.0", 19 | "lucide-react": "^0.475.0", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "tailwind-merge": "^2.5.2", 23 | "tailwindcss-animate": "^1.0.7" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.9.0", 27 | "@types/react": "^18.3.3", 28 | "@types/react-dom": "^18.3.0", 29 | "@vitejs/plugin-react-swc": "^3.5.0", 30 | "autoprefixer": "^10.4.20", 31 | "eslint": "^9.9.0", 32 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 33 | "eslint-plugin-react-refresh": "^0.4.9", 34 | "globals": "^15.9.0", 35 | "postcss": "^8.4.42", 36 | "prettier": "^3.3.3", 37 | "prettier-plugin-tailwindcss": "^0.6.8", 38 | "tailwindcss": "^3.4.10", 39 | "typescript": "^5.5.3", 40 | "typescript-eslint": "^8.0.1", 41 | "vite": "^5.4.1" 42 | } 43 | } -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergi0g/cup/b542f1bac52a886ea9f8e78b60121efbf7b4d335/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> 3 | <svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" 4 | viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve"> 5 | <path style="fill:#A6CFD6;" d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95 6 | c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06 7 | C97.82,30.66,94.2,18.43,65.12,17.55z"/> 8 | <path style="fill:#DCEDF6;" d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16 9 | c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"/> 10 | <path style="fill:#6CA4AE;" d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7 11 | S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"/> 12 | <path style="fill:#DC0D27;" d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06 13 | s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73 14 | C110.47,3.47,86.08,11.74,84.85,13.1z"/> 15 | <path style="fill:#8A1F0F;" d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47 16 | c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"/> 17 | <g> 18 | <path style="fill:#8A1F0F;" d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97 19 | c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"/> 20 | </g> 21 | <g> 22 | <path style="fill:#8A1F0F;" d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99 23 | c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"/> 24 | </g> 25 | <g> 26 | <path style="fill:#8A1F0F;" d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96 27 | c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"/> 28 | </g> 29 | </svg> 30 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Logo from "./components/Logo"; 3 | import Statistic from "./components/Statistic"; 4 | import Image from "./components/Image"; 5 | import { LastChecked } from "./components/LastChecked"; 6 | import Loading from "./components/Loading"; 7 | import { Filters as FiltersType } from "./types"; 8 | import { theme } from "./theme"; 9 | import RefreshButton from "./components/RefreshButton"; 10 | import Search from "./components/Search"; 11 | import { Server } from "./components/Server"; 12 | import { useData } from "./hooks/use-data"; 13 | import DataLoadingError from "./components/DataLoadingError"; 14 | import Filters from "./components/Filters"; 15 | import { Filter, FilterX } from "lucide-react"; 16 | import { WithTooltip } from "./components/ui/Tooltip"; 17 | import { getDescription } from "./utils"; 18 | 19 | const SORT_ORDER = [ 20 | "monitored_images", 21 | "updates_available", 22 | "major_updates", 23 | "minor_updates", 24 | "patch_updates", 25 | "other_updates", 26 | "up_to_date", 27 | "unknown", 28 | ]; 29 | 30 | function App() { 31 | const { data, isLoading, isError } = useData(); 32 | 33 | const [showFilters, setShowFilters] = useState<boolean>(false); 34 | const [filters, setFilters] = useState<FiltersType>({ 35 | onlyInUse: false, 36 | registries: [], 37 | statuses: [], 38 | }); 39 | const [searchQuery, setSearchQuery] = useState(""); 40 | 41 | if (isLoading) return <Loading />; 42 | if (isError || !data) return <DataLoadingError />; 43 | const toggleShowFilters = () => { 44 | if (showFilters) { 45 | setFilters({ onlyInUse: false, registries: [], statuses: [] }); 46 | } 47 | setShowFilters(!showFilters); 48 | }; 49 | 50 | return ( 51 | <div 52 | className={`flex min-h-screen justify-center bg-white dark:bg-${theme}-950`} 53 | > 54 | <div className="mx-auto h-full w-full max-w-[80rem] px-4 sm:px-6 lg:px-8"> 55 | <div className="mx-auto my-8 flex h-full max-w-[48rem] flex-col"> 56 | <div className="flex items-center gap-1"> 57 | <h1 className="text-5xl font-bold tracking-tight lg:text-6xl dark:text-white"> 58 | Cup 59 | </h1> 60 | <Logo /> 61 | </div> 62 | <div 63 | className={`border shadow-sm border-${theme}-200 dark:border-${theme}-900 my-8 rounded-md`} 64 | > 65 | <dl className="grid grid-cols-2 gap-1 overflow-hidden *:relative lg:grid-cols-4"> 66 | {Object.entries(data.metrics) 67 | .sort((a, b) => { 68 | return SORT_ORDER.indexOf(a[0]) - SORT_ORDER.indexOf(b[0]); 69 | }) 70 | .map(([name]) => ( 71 | <Statistic 72 | name={name as keyof typeof data.metrics} 73 | metrics={data.metrics} 74 | key={name} 75 | /> 76 | ))} 77 | </dl> 78 | </div> 79 | <div 80 | className={`border shadow-sm border-${theme}-200 dark:border-${theme}-900 my-8 rounded-md`} 81 | > 82 | <div 83 | className={`flex items-center justify-between gap-3 px-6 py-4 text-${theme}-500`} 84 | > 85 | <LastChecked datetime={data.last_updated} /> 86 | <div className="flex gap-3"> 87 | <WithTooltip 88 | text={showFilters ? "Clear filters" : "Show filters"} 89 | > 90 | <button onClick={toggleShowFilters}> 91 | {showFilters ? <FilterX /> : <Filter />} 92 | </button> 93 | </WithTooltip> 94 | <RefreshButton /> 95 | </div> 96 | </div> 97 | <div className="flex gap-2 px-6 text-black dark:text-white"> 98 | <Search onChange={setSearchQuery} /> 99 | </div> 100 | {showFilters && ( 101 | <Filters 102 | filters={filters} 103 | setFilters={setFilters} 104 | registries={[ 105 | ...new Set(data.images.map((image) => image.parts.registry)), 106 | ]} 107 | /> 108 | )} 109 | <ul> 110 | {Object.entries( 111 | data.images.reduce<Record<string, typeof data.images>>( 112 | (acc, image) => { 113 | const server = image.server ?? ""; 114 | if (!Object.hasOwn(acc, server)) acc[server] = []; 115 | acc[server].push(image); 116 | return acc; 117 | }, 118 | {}, 119 | ), 120 | ) 121 | .sort() 122 | .map(([server, images]) => ( 123 | <Server name={server} key={server}> 124 | {images 125 | .filter((image) => 126 | filters.onlyInUse ? !!image.in_use : true, 127 | ) 128 | .filter((image) => 129 | filters.registries.length == 0 130 | ? true 131 | : filters.registries.includes(image.parts.registry), 132 | ) 133 | .filter((image) => 134 | filters.statuses.length == 0 135 | ? true 136 | : filters.statuses.includes(getDescription(image)), 137 | ) 138 | .filter((image) => image.reference.includes(searchQuery)) 139 | .map((image) => ( 140 | <Image data={image} key={image.reference} /> 141 | ))} 142 | </Server> 143 | ))} 144 | </ul> 145 | </div> 146 | </div> 147 | </div> 148 | </div> 149 | ); 150 | } 151 | 152 | export default App; 153 | -------------------------------------------------------------------------------- /web/src/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight } from "lucide-react"; 2 | import { theme } from "../theme"; 3 | 4 | export default function Badge({ from, to }: { from: string; to: string }) { 5 | return ( 6 | <span 7 | 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`} 8 | > 9 | {from} 10 | <ArrowRight className="size-3" /> 11 | {to} 12 | </span> 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /web/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | import { theme } from "../theme"; 3 | import { Clipboard, ClipboardCheck } from "lucide-react"; 4 | 5 | export function CodeBlock({ 6 | children, 7 | enableCopy, 8 | }: { 9 | children: ReactNode; 10 | enableCopy?: boolean; 11 | }) { 12 | const [copySuccess, setCopySuccess] = useState(false); 13 | const handleCopy = (text: string) => { 14 | return () => { 15 | navigator.clipboard.writeText(text).then(() => { 16 | setCopySuccess(true); 17 | setTimeout(() => { 18 | setCopySuccess(false); 19 | }, 3000); 20 | }); 21 | }; 22 | }; 23 | 24 | const copyText = children instanceof Array ? children.join("") : children; 25 | 26 | return ( 27 | <div 28 | 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`} 29 | > 30 | <p className="overflow-scroll">{children}</p> 31 | {enableCopy && 32 | navigator.clipboard && 33 | (copySuccess ? ( 34 | <ClipboardCheck 35 | className={`absolute right-3 size-7 bg-${theme}-100 py-1 pl-2 dark:bg-${theme}-950`} 36 | /> 37 | ) : ( 38 | <button 39 | 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`} 40 | onClick={handleCopy(`${copyText}`)} 41 | > 42 | <Clipboard className="size-5" /> 43 | </button> 44 | ))} 45 | </div> 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /web/src/components/DataLoadingError.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "./Logo"; 2 | import { theme } from "../theme"; 3 | 4 | const DataLoadingError = () => { 5 | return ( 6 | <div 7 | className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`} 8 | > 9 | <div className="absolute mx-auto h-full w-full max-w-[80rem] overflow-hidden px-4 sm:px-6 lg:px-8"> 10 | <div className="mx-auto my-8 flex h-full max-w-[48rem] flex-col"> 11 | <div className="flex items-center gap-1"> 12 | <h1 className="text-5xl font-bold lg:text-6xl dark:text-white"> 13 | Cup 14 | </h1> 15 | <Logo /> 16 | </div> 17 | <div 18 | className={`flex h-full flex-col items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`} 19 | > 20 | <div className="mb-8 flex gap-1"> 21 | An error occurred, please try again. 22 | </div> 23 | </div> 24 | </div> 25 | </div> 26 | </div> 27 | ); 28 | }; 29 | 30 | export default DataLoadingError; 31 | -------------------------------------------------------------------------------- /web/src/components/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { theme } from "../theme"; 3 | import { Filters as FiltersType } from "../types"; 4 | import { Checkbox } from "./ui/Checkbox"; 5 | import Select from "./ui/Select"; 6 | import { Server } from "lucide-react"; 7 | 8 | interface Props { 9 | filters: FiltersType; 10 | setFilters: (filters: FiltersType) => void; 11 | registries: string[]; 12 | } 13 | 14 | const STATUSES = [ 15 | "Major update", 16 | "Minor update", 17 | "Patch update", 18 | "Digest update", 19 | "Up to date", 20 | "Unknown", 21 | ]; 22 | 23 | export default function Filters({ filters, setFilters, registries }: Props) { 24 | const [selectedRegistries, setSelectedRegistries] = useState< 25 | FiltersType["registries"] 26 | >([]); 27 | const [selectedStatuses, setSelectedStatuses] = useState< 28 | FiltersType["statuses"] 29 | >([]); 30 | const handleSelectRegistries = (registries: string[]) => { 31 | setSelectedRegistries(registries); 32 | setFilters({ 33 | ...filters, 34 | registries, 35 | }); 36 | }; 37 | const handleSelectStatuses = (statuses: string[]) => { 38 | if (statuses.every((status) => STATUSES.includes(status))) { 39 | setSelectedStatuses(statuses as FiltersType["statuses"]); 40 | setFilters({ 41 | ...filters, 42 | statuses: statuses as FiltersType["statuses"], 43 | }); 44 | } 45 | }; 46 | return ( 47 | <div className="flex w-full flex-col gap-4 px-6 py-4 sm:flex-row"> 48 | <div className="flex items-center space-x-2"> 49 | <Checkbox 50 | id="inUse" 51 | checked={filters.onlyInUse} 52 | onCheckedChange={(value) => { 53 | setFilters({ 54 | ...filters, 55 | onlyInUse: value === "indeterminate" ? false : value, 56 | }); 57 | }} 58 | /> 59 | <label 60 | htmlFor="inUse" 61 | className={`text-sm font-medium leading-none text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 hover:text-black peer-hover:text-black peer-data-[state=checked]:text-black dark:hover:text-white peer-hover:dark:text-white dark:peer-data-[state=checked]:text-white`} 62 | > 63 | Hide unused images 64 | </label> 65 | </div> 66 | <Select 67 | Icon={Server} 68 | items={registries} 69 | placeholder="Registry" 70 | selectedItems={selectedRegistries} 71 | setSelectedItems={handleSelectRegistries} 72 | /> 73 | <Select 74 | items={STATUSES} 75 | placeholder="Update type" 76 | selectedItems={selectedStatuses} 77 | setSelectedItems={handleSelectStatuses} 78 | /> 79 | </div> 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /web/src/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Dialog, 4 | DialogBackdrop, 5 | DialogPanel, 6 | DialogTitle, 7 | } from "@headlessui/react"; 8 | import { WithTooltip } from "./ui/Tooltip"; 9 | import type { Image } from "../types"; 10 | import { theme } from "../theme"; 11 | import { CodeBlock } from "./CodeBlock"; 12 | import { 13 | Box, 14 | CircleArrowUp, 15 | CircleCheck, 16 | HelpCircle, 17 | Timer, 18 | TriangleAlert, 19 | X, 20 | } from "lucide-react"; 21 | import Badge from "./Badge"; 22 | import { getDescription } from "../utils"; 23 | 24 | const clickable_registries = [ 25 | "registry-1.docker.io", 26 | "ghcr.io", 27 | "quay.io", 28 | "gcr.io", 29 | ]; // Not all registries redirect to an info page when visiting the image reference in a browser (e.g. Gitea and derivatives), so we only enable clicking those who do. 30 | 31 | export default function Image({ data }: { data: Image }) { 32 | const [open, setOpen] = useState(false); 33 | const handleOpen = () => { 34 | setOpen(true); 35 | }; 36 | const handleClose = () => { 37 | setOpen(false); 38 | }; 39 | const new_reference = 40 | data.result.info?.type == "version" 41 | ? data.reference.split(":")[0] + ":" + data.result.info.new_tag 42 | : data.reference; 43 | const info = getInfo(data); 44 | let url: string | null = null; 45 | if (data.url) { 46 | url = data.url; 47 | } else if (clickable_registries.includes(data.parts.registry)) { 48 | switch (data.parts.registry) { 49 | case "registry-1.docker.io": 50 | url = `https://hub.docker.com/r/${data.parts.repository}`; 51 | break; 52 | default: 53 | url = `https://${data.parts.registry}/${data.parts.repository}`; 54 | break; 55 | } 56 | } 57 | return ( 58 | <> 59 | <button onClick={handleOpen} className="w-full"> 60 | <li 61 | className={`flex items-center gap-4 break-all px-6 py-4 text-start hover:bg-${theme}-100 hover:dark:bg-${theme}-900/50 transition-colors duration-200`} 62 | > 63 | <Box className={`size-6 shrink-0 text-${theme}-500`} /> 64 | <span className="font-mono">{data.reference}</span> 65 | <div className="ml-auto flex gap-2"> 66 | {data.result.info?.type === "version" ? ( 67 | <Badge 68 | from={data.result.info.current_version} 69 | to={data.result.info.new_version} 70 | /> 71 | ) : null} 72 | <WithTooltip 73 | text={info.description} 74 | className={`size-6 shrink-0 ${info.color}`} 75 | > 76 | <info.icon /> 77 | </WithTooltip> 78 | </div> 79 | </li> 80 | </button> 81 | <Dialog open={open} onClose={setOpen} className="relative z-10"> 82 | <DialogBackdrop 83 | transition 84 | className={`fixed inset-0 bg-${theme}-500 dark:bg-${theme}-950 !bg-opacity-75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in`} 85 | /> 86 | <div className="fixed inset-0 z-10 w-screen overflow-y-auto"> 87 | <div className="flex min-h-full items-end justify-center text-center sm:items-center sm:p-0"> 88 | <DialogPanel 89 | transition 90 | className={`relative transform overflow-hidden rounded-t-lg bg-white dark:border dark:border-${theme}-800 md:rounded-lg dark:bg-${theme}-900 w-full text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:w-full sm:max-w-lg data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95 md:max-w-xl lg:max-w-2xl dark:text-white`} 91 | > 92 | <div 93 | className={`flex flex-col gap-3 px-6 py-4 text-${theme}-600 dark:text-${theme}-400`} 94 | > 95 | <div className="mb-4 flex items-center gap-3"> 96 | <Box className={`size-6 shrink-0 text-${theme}-500`} /> 97 | <DialogTitle className="break-all font-mono text-black dark:text-white"> 98 | {url ? ( 99 | <> 100 | <a 101 | href={url} 102 | target="_blank" 103 | rel="noopener noreferrer" 104 | className={`group w-fit hover:underline`} 105 | > 106 | <span> 107 | {data.reference} 108 | <svg 109 | viewBox="0 0 12 12" 110 | fill="none" 111 | height="1cap" 112 | xmlns="http://www.w3.org/2000/svg" 113 | className="ml-1 inline transition-all duration-100 group-hover:rotate-45" 114 | > 115 | <path 116 | d="M11 9.283V1H2.727v1.44h5.83L1 9.99 2.01 11l7.556-7.55v5.833H11Z" 117 | fill="currentColor" 118 | ></path> 119 | </svg> 120 | </span> 121 | </a> 122 | </> 123 | ) : ( 124 | data.reference 125 | )} 126 | </DialogTitle> 127 | <button onClick={handleClose} className="ml-auto"> 128 | <X 129 | className={`size-6 shrink-0 text-${theme}-500 transition-colors duration-200 hover:text-black dark:hover:text-white`} 130 | /> 131 | </button> 132 | </div> 133 | <div className="flex items-center gap-3"> 134 | <info.icon className={`size-6 shrink-0 ${info.color}`} /> 135 | {info.description} 136 | </div> 137 | <div className="flex items-center gap-3"> 138 | <Timer className="size-6 shrink-0 text-gray-500" /> 139 | <span> 140 | Checked in <b>{data.time}</b> ms 141 | </span> 142 | </div> 143 | {data.result.error && ( 144 | <div className="break-before mt-4 flex items-center gap-3 overflow-hidden rounded-md bg-yellow-400/10 px-3 py-2"> 145 | <TriangleAlert className="size-6 shrink-0 text-yellow-500" /> 146 | {data.result.error} 147 | </div> 148 | )} 149 | {data.result.has_update && ( 150 | <div className="mt-4 flex flex-col gap-1"> 151 | Pull command 152 | <CodeBlock enableCopy> 153 | docker pull {new_reference} 154 | </CodeBlock> 155 | </div> 156 | )} 157 | <div className="flex flex-col gap-1"> 158 | {data.result.info?.type == "digest" && ( 159 | <> 160 | {data.result.info.local_digests.length > 1 161 | ? "Local digests" 162 | : "Local digest"} 163 | <CodeBlock enableCopy> 164 | {data.result.info.local_digests.join("\n")} 165 | </CodeBlock> 166 | {data.result.info.remote_digest && ( 167 | <div className="flex flex-col gap-1"> 168 | Remote digest 169 | <CodeBlock enableCopy> 170 | {data.result.info.remote_digest} 171 | </CodeBlock> 172 | </div> 173 | )} 174 | </> 175 | )} 176 | </div> 177 | </div> 178 | </DialogPanel> 179 | </div> 180 | </div> 181 | </Dialog> 182 | </> 183 | ); 184 | } 185 | 186 | function getInfo(data: Image): { 187 | color: string; 188 | icon: typeof HelpCircle; 189 | description: string; 190 | } { 191 | const description = getDescription(data); 192 | switch (description) { 193 | case "Unknown": 194 | return { 195 | color: "text-gray-500", 196 | icon: HelpCircle, 197 | description, 198 | }; 199 | case "Up to date": 200 | return { 201 | color: "text-green-500", 202 | icon: CircleCheck, 203 | description, 204 | }; 205 | 206 | case "Major update": 207 | return { 208 | color: "text-red-500", 209 | icon: CircleArrowUp, 210 | description, 211 | }; 212 | case "Minor update": 213 | return { 214 | color: "text-yellow-500", 215 | icon: CircleArrowUp, 216 | description, 217 | }; 218 | case "Patch update": 219 | return { 220 | color: "text-blue-500", 221 | icon: CircleArrowUp, 222 | description, 223 | }; 224 | case "Digest update": 225 | return { 226 | color: "text-blue-500", 227 | icon: CircleArrowUp, 228 | description, 229 | }; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /web/src/components/LastChecked.tsx: -------------------------------------------------------------------------------- 1 | import { intlFormatDistance } from "date-fns/intlFormatDistance"; 2 | import { theme } from "../theme"; 3 | 4 | export function LastChecked({ datetime }: { datetime: string }) { 5 | const date = intlFormatDistance(new Date(datetime), new Date()); 6 | return ( 7 | <h3 className={`text-${theme}-600 dark:text-${theme}-500`}> 8 | Last checked {date} 9 | </h3> 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "./Logo"; 2 | import { theme } from "../theme"; 3 | import { LoaderCircle } from "lucide-react"; 4 | 5 | export default function Loading() { 6 | return ( 7 | <div 8 | className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`} 9 | > 10 | <div className="absolute mx-auto h-full w-full max-w-[80rem] overflow-hidden px-4 sm:px-6 lg:px-8"> 11 | <div className="mx-auto my-8 flex h-full max-w-[48rem] flex-col"> 12 | <div className="flex items-center gap-1"> 13 | <h1 className="text-5xl font-bold lg:text-6xl dark:text-white"> 14 | Cup 15 | </h1> 16 | <Logo /> 17 | </div> 18 | <div 19 | className={`flex h-full flex-col items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`} 20 | > 21 | <div className="mb-8 flex gap-1"> 22 | Loading <LoaderCircle className="animate-spin" /> 23 | </div> 24 | <p> 25 | If this takes more than a few seconds, there was probably a 26 | problem fetching the data. Please try reloading the page and 27 | report a bug if the problem persists. 28 | </p> 29 | </div> 30 | </div> 31 | </div> 32 | </div> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /web/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 | <svg viewBox="0 0 128 128" className="size-14 lg:size-16"> 4 | <path 5 | style={{ fill: "#A6CFD6" }} 6 | d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95 7 | c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06 8 | C97.82,30.66,94.2,18.43,65.12,17.55z" 9 | /> 10 | <path 11 | style={{ fill: "#DCEDF6" }} 12 | d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16 13 | c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z" 14 | /> 15 | <path 16 | style={{ fill: "#6CA4AE" }} 17 | d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7 18 | S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z" 19 | /> 20 | <path 21 | style={{ fill: "#DC0D27" }} 22 | d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06 23 | s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73 24 | C110.47,3.47,86.08,11.74,84.85,13.1z" 25 | /> 26 | <path 27 | style={{ fill: "#8A1F0F" }} 28 | d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47 29 | c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z" 30 | /> 31 | <g> 32 | <path 33 | style={{ fill: "#8A1F0F" }} 34 | d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97 35 | c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z" 36 | /> 37 | </g> 38 | <g> 39 | <path 40 | style={{ fill: "#8A1F0F" }} 41 | d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99 42 | c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z" 43 | /> 44 | </g> 45 | <g> 46 | <path 47 | style={{ fill: "#8A1F0F" }} 48 | d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96 49 | c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z" 50 | /> 51 | </g> 52 | </svg> 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { WithTooltip } from "./ui/Tooltip"; 3 | 4 | export default function RefreshButton() { 5 | const [disabled, setDisabled] = useState(false); 6 | const refresh = () => { 7 | setDisabled(true); 8 | const request = new XMLHttpRequest(); 9 | request.onload = () => { 10 | if (request.status === 200) { 11 | window.location.reload(); 12 | } 13 | }; 14 | request.open( 15 | "GET", 16 | process.env.NODE_ENV === "production" 17 | ? "./api/v3/refresh" 18 | : `http://${window.location.hostname}:8000/api/v3/refresh`, 19 | ); 20 | request.send(); 21 | }; 22 | return ( 23 | <WithTooltip text="Reload"> 24 | <button className="group shrink-0" onClick={refresh} disabled={disabled}> 25 | <svg 26 | xmlns="http://www.w3.org/2000/svg" 27 | width="24" 28 | height="24" 29 | viewBox="0 0 24 24" 30 | fill="none" 31 | stroke="currentColor" 32 | strokeWidth="2" 33 | strokeLinecap="round" 34 | strokeLinejoin="round" 35 | className="size-6 group-disabled:animate-spin" 36 | > 37 | <path stroke="none" d="M0 0h24v24H0z" fill="none" /> 38 | <path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" /> 39 | <path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" /> 40 | </svg> 41 | </button> 42 | </WithTooltip> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /web/src/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState } from "react"; 2 | import { theme } from "../theme"; 3 | import { SearchIcon, X } from "lucide-react"; 4 | 5 | export default function Search({ 6 | onChange, 7 | }: { 8 | onChange: (value: string) => void; 9 | }) { 10 | const [searchQuery, setSearchQuery] = useState(""); 11 | const [showClear, setShowClear] = useState(false); 12 | const handleChange = (event: ChangeEvent) => { 13 | const value = (event.target as HTMLInputElement).value; 14 | setSearchQuery(value); 15 | onChange(value); 16 | if (value !== "") { 17 | setShowClear(true); 18 | } else setShowClear(false); 19 | }; 20 | const handleClear = () => { 21 | setShowClear(false); 22 | setSearchQuery(""); 23 | onChange(""); 24 | }; 25 | return ( 26 | <div 27 | 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`} 28 | > 29 | <SearchIcon 30 | className={`size-5 text-${theme}-600 dark:text-${theme}-400`} 31 | /> 32 | <div className="w-full"> 33 | <input 34 | 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`} 35 | placeholder="Search" 36 | onChange={handleChange} 37 | value={searchQuery} 38 | ></input> 39 | </div> 40 | {showClear && ( 41 | <button 42 | onClick={handleClear} 43 | className={`hover:text-${theme}-600 dark:hover:text-${theme}-400 transition-colors duration-200`} 44 | > 45 | <X className="size-5" /> 46 | </button> 47 | )} 48 | <div 49 | 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)]" 50 | style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }} 51 | ></div> 52 | </div> 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/Server.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Disclosure, 3 | DisclosureButton, 4 | DisclosurePanel, 5 | } from "@headlessui/react"; 6 | import { theme } from "../theme"; 7 | import { ChevronDown } from "lucide-react"; 8 | 9 | export function Server({ 10 | name, 11 | children, 12 | }: { 13 | name: string; 14 | children: React.ReactNode; 15 | }) { 16 | if (name.length === 0) name = "Local images"; 17 | return ( 18 | <Disclosure defaultOpen as="li" className={`mb-4 last:mb-0`}> 19 | <DisclosureButton className="group my-4 flex w-full items-center justify-between px-6"> 20 | <span 21 | className={`text-lg font-semibold text-${theme}-600 dark:text-${theme}-400 group-data-[hover]:text-${theme}-800 group-data-[hover]:dark:text-${theme}-200 transition-colors duration-300`} 22 | > 23 | {name} 24 | </span> 25 | <ChevronDown 26 | className={`size-5 duration-300 text-${theme}-600 transition-transform group-data-[open]:rotate-180 dark:text-${theme}-400 group-data-[hover]:text-${theme}-800 group-data-[hover]:dark:text-${theme}-200 transition-colors`} 27 | /> 28 | </DisclosureButton> 29 | <DisclosurePanel 30 | className={`dark:divide-${theme}-900 divide-y dark:text-white`} 31 | as="ul" 32 | transition 33 | > 34 | {children} 35 | </DisclosurePanel> 36 | </Disclosure> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /web/src/components/Statistic.tsx: -------------------------------------------------------------------------------- 1 | import { CircleArrowUp, CircleCheck, Eye, HelpCircle } from "lucide-react"; 2 | import { theme } from "../theme"; 3 | import { Data } from "../types"; 4 | 5 | const metricsToShow = [ 6 | "monitored_images", 7 | "up_to_date", 8 | "updates_available", 9 | "unknown", 10 | ]; 11 | 12 | export default function Statistic({ 13 | name, 14 | metrics, 15 | }: { 16 | name: keyof Data["metrics"]; 17 | metrics: Data["metrics"]; 18 | }) { 19 | if (!metricsToShow.includes(name)) return null; 20 | const displayName = name.replaceAll("_", " "); 21 | return ( 22 | <div 23 | className={`before:bg-${theme}-200 before:dark:bg-${theme}-900 after:bg-${theme}-200 after:dark:bg-${theme}-900 gi`} 24 | > 25 | <div className="flex h-full flex-col justify-between gap-x-4 gap-y-2 px-6 py-4 align-baseline lg:min-h-32"> 26 | <dt 27 | className={`text-${theme}-500 dark:text-${theme}-400 text-sm font-semibold uppercase leading-6`} 28 | > 29 | {displayName} 30 | </dt> 31 | <div className="flex items-center justify-between gap-1"> 32 | <dd className="w-full text-3xl font-medium leading-10 tracking-tight text-black dark:text-white"> 33 | {metrics[name]} 34 | </dd> 35 | {name === "monitored_images" && ( 36 | <Eye className="size-6 shrink-0 text-black dark:text-white" /> 37 | )} 38 | {name === "up_to_date" && ( 39 | <CircleCheck className="size-6 shrink-0 text-green-500" /> 40 | )} 41 | {name === "updates_available" && getUpdatesAvailableIcon(metrics)} 42 | {name === "unknown" && ( 43 | <HelpCircle className="size-6 shrink-0 text-gray-500" /> 44 | )} 45 | </div> 46 | </div> 47 | </div> 48 | ); 49 | } 50 | 51 | function getUpdatesAvailableIcon(metrics: Data["metrics"]) { 52 | const filteredMetrics = Object.entries(metrics).filter( 53 | ([key]) => !metricsToShow.includes(key), 54 | ); 55 | const maxMetric = filteredMetrics.reduce((max, current) => { 56 | if (Number(current[1]) > Number(max[1])) { 57 | return current; 58 | } 59 | return max; 60 | }, filteredMetrics[0])[0]; 61 | let color = ""; 62 | switch (maxMetric) { 63 | case "major_updates": 64 | color = "text-red-500"; 65 | break; 66 | case "minor_updates": 67 | color = "text-yellow-500"; 68 | break; 69 | default: 70 | color = "text-blue-500"; 71 | } 72 | return <CircleArrowUp className={`size-6 shrink-0 ${color}`} />; 73 | } 74 | -------------------------------------------------------------------------------- /web/src/components/ui/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | import { cn } from "../../utils"; 7 | import { theme } from "../../theme"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef<typeof CheckboxPrimitive.Root>, 11 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> 12 | >(({ className, ...props }, ref) => ( 13 | <CheckboxPrimitive.Root 14 | ref={ref} 15 | className={cn( 16 | `border-${theme}-600 dark:border-${theme}-400 group peer h-4 w-4 shrink-0 rounded-sm border shadow transition-colors duration-200 hover:border-black focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 data-[state=checked]:border-0 data-[state=checked]:bg-blue-500 data-[state=checked]:text-white hover:data-[state=checked]:bg-blue-600 dark:hover:border-white dark:hover:data-[state=checked]:bg-blue-400`, 17 | className, 18 | )} 19 | {...props} 20 | > 21 | <CheckboxPrimitive.Indicator 22 | className={cn("flex items-center justify-center text-current")} 23 | > 24 | <Check 25 | className={`h-3 w-3 group-data-[state=checked]:text-white dark:group-data-[state=checked]:text-${theme}-950`} 26 | strokeWidth={3} 27 | /> 28 | </CheckboxPrimitive.Indicator> 29 | </CheckboxPrimitive.Root> 30 | )); 31 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 32 | 33 | export { Checkbox }; 34 | -------------------------------------------------------------------------------- /web/src/components/ui/Select.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Listbox, 3 | ListboxButton, 4 | ListboxOptions, 5 | ListboxOption, 6 | } from "@headlessui/react"; 7 | import { ChevronDown, Check } from "lucide-react"; 8 | import { theme } from "../../theme"; 9 | import { cn } from "../../utils"; 10 | import { Server } from "lucide-react"; 11 | 12 | export default function Select({ 13 | items, 14 | Icon, 15 | placeholder, 16 | selectedItems, 17 | setSelectedItems, 18 | }: { 19 | items: string[]; 20 | Icon?: typeof Server; 21 | placeholder: string; 22 | selectedItems: string[]; 23 | setSelectedItems: (items: string[]) => void; 24 | }) { 25 | return ( 26 | <Listbox value={selectedItems} onChange={setSelectedItems} multiple> 27 | <div className="relative"> 28 | <ListboxButton 29 | className={cn( 30 | `flex overflow-x-hidden w-full gap-2 rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 group relative items-center py-1.5 pl-3 pr-2 text-left transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 sm:text-sm/6`, 31 | selectedItems.length == 0 32 | ? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white` 33 | : "text-black dark:text-white", 34 | )} 35 | > 36 | {Icon && ( 37 | <Icon 38 | className={cn( 39 | "size-4 shrink-0", 40 | selectedItems.length == 0 41 | ? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white` 42 | : "text-black dark:text-white", 43 | )} 44 | /> 45 | )} 46 | <span className="truncate"> 47 | {selectedItems.length == 0 48 | ? placeholder 49 | : selectedItems.length == 1 50 | ? selectedItems[0] 51 | : `${selectedItems[0]} +${(selectedItems.length - 1).toString()} more`}</span> 52 | 53 | <ChevronDown 54 | aria-hidden="true" 55 | className={`size-5 shrink-0 ml-auto self-center text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 group-hover:text-black sm:size-4 group-hover:dark:text-white`} 56 | /> 57 | <div 58 | className="absolute -bottom-px left-1/2 h-full w-0 -translate-x-1/2 rounded-md border-b-2 border-b-blue-600 transition-all duration-200 group-data-[open]:w-[calc(100%+2px)]" 59 | style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }} 60 | ></div> 61 | </ListboxButton> 62 | <ListboxOptions 63 | transition 64 | className={`absolute z-10 mt-1 max-h-56 w-max overflow-y-auto overflow-x-hidden rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 text-base shadow-lg ring-1 ring-black/5 focus:outline-none data-[closed]:data-[leave]:opacity-0 data-[leave]:transition data-[leave]:duration-100 data-[leave]:ease-in sm:text-sm`} 65 | > 66 | {items.map((item) => ( 67 | <ListboxOption 68 | key={item} 69 | value={item} 70 | className={`group relative cursor-pointer text-nowrap py-2 pl-3 pr-9 data-[focus]:outline-none text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 data-[focus]:bg-black/10 data-[focus]:text-black dark:data-[focus]:bg-white/10 data-[focus]:dark:text-white`} 71 | > 72 | {item} 73 | <span 74 | className={`absolute inset-y-0 right-2 flex items-center text-${theme}-600 dark:text-${theme}-400 group-[:not([data-selected])]:hidden group-data-[focus]:text-black group-data-[focus]:dark:text-white`} 75 | > 76 | <Check aria-hidden="true" className="size-4" /> 77 | </span> 78 | </ListboxOption> 79 | ))} 80 | </ListboxOptions> 81 | </div> 82 | </Listbox> 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /web/src/components/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip"; 2 | import { cn } from "../../utils"; 3 | import { forwardRef, ReactNode } from "react"; 4 | import { theme } from "../../theme"; 5 | 6 | const TooltipContent = forwardRef< 7 | React.ElementRef<typeof Content>, 8 | React.ComponentPropsWithoutRef<typeof Content> 9 | >(({ className, sideOffset = 4, ...props }, ref) => ( 10 | <Content 11 | ref={ref} 12 | sideOffset={sideOffset} 13 | className={cn( 14 | `z-50 overflow-hidden rounded-md border border-${theme}-200 dark:border-${theme}-800 bg-white px-3 py-1.5 text-sm text-${theme}-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-${theme}-800 dark:bg-${theme}-950 dark:text-${theme}-50`, 15 | className, 16 | )} 17 | {...props} 18 | /> 19 | )); 20 | 21 | TooltipContent.displayName = Content.displayName; 22 | 23 | const WithTooltip = ({ 24 | children, 25 | text, 26 | className, 27 | }: { 28 | children: ReactNode; 29 | text: string; 30 | className?: string; 31 | }) => { 32 | return ( 33 | <Provider> 34 | <Root> 35 | <Trigger className={className} asChild> 36 | {children} 37 | </Trigger> 38 | <TooltipContent> 39 | <p className="text-black dark:text-white">{text}</p> 40 | </TooltipContent> 41 | </Root> 42 | </Provider> 43 | ); 44 | }; 45 | 46 | export { WithTooltip }; 47 | -------------------------------------------------------------------------------- /web/src/hooks/use-data.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { Data } from "../types"; 3 | 4 | export const useData = () => { 5 | const [isLoading, setIsLoading] = useState(false); 6 | const [isError, setIsError] = useState(false); 7 | const [data, setData] = useState<Data | null>(null); 8 | 9 | useEffect(() => { 10 | if (isLoading || isError || !!data) return; 11 | setIsLoading(true); 12 | setIsError(false); 13 | setData(null); 14 | fetch( 15 | process.env.NODE_ENV === "production" 16 | ? "./api/v3/json" 17 | : `http://${window.location.hostname}:8000/api/v3/json`, 18 | ) 19 | .then((response) => { 20 | if (response.ok) return response.json(); 21 | throw new Error("Failed to fetch data"); 22 | }) 23 | .then((data) => { 24 | setData(data as Data); 25 | }) 26 | .catch((error: unknown) => { 27 | setIsError(true); 28 | console.error(error); 29 | }) 30 | .finally(() => { 31 | setIsLoading(false); 32 | }); 33 | }, [data, isError, isLoading]); 34 | 35 | return { 36 | data, 37 | isLoading, 38 | isError, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */ 6 | .gi { 7 | position: relative; 8 | height: 100%; 9 | } 10 | 11 | .gi::before, 12 | .gi::after { 13 | content: ""; 14 | position: absolute; 15 | z-index: 1; 16 | } 17 | 18 | .gi::before { 19 | inline-size: 1px; 20 | block-size: 100vh; 21 | inset-inline-start: -0.125rem; 22 | } 23 | 24 | .gi::after { 25 | inline-size: 100vw; 26 | block-size: 1px; 27 | inset-inline-start: 0; 28 | inset-block-start: -0.12rem; 29 | } 30 | 31 | @supports (scrollbar-color: auto) { 32 | html { 33 | scrollbar-color: #707070 #343840; 34 | } 35 | } 36 | 37 | @supports selector(::-webkit-scrollbar) { 38 | html::-webkit-scrollbar { 39 | width: 10px; 40 | } 41 | 42 | html::-webkit-scrollbar-track { 43 | background: #343840; 44 | } 45 | 46 | html::-webkit-scrollbar-thumb { 47 | background: #707070; 48 | border-radius: 0.375rem; 49 | } 50 | 51 | html::-webkit-scrollbar-thumb:hover { 52 | background: #b5b5b5; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | createRoot(document.getElementById("root")!).render( 7 | <StrictMode> 8 | <App /> 9 | </StrictMode>, 10 | ); 11 | -------------------------------------------------------------------------------- /web/src/theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = "neutral"; // Will be modified by server at runtime 2 | -------------------------------------------------------------------------------- /web/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Data { 2 | metrics: { 3 | monitored_images: number; 4 | up_to_date: number; 5 | updates_available: number; 6 | major_updates: number; 7 | minor_updates: number; 8 | patch_updates: number; 9 | other_updates: number; 10 | unknown: number; 11 | }; 12 | images: Image[]; 13 | last_updated: string; 14 | } 15 | 16 | export interface Image { 17 | reference: string; 18 | parts: { 19 | registry: string; 20 | repository: string; 21 | tag: string; 22 | }; 23 | url: string | null; 24 | result: { 25 | has_update: boolean | null; 26 | info: VersionInfo | DigestInfo | null; 27 | error: string | null; 28 | }; 29 | time: number; 30 | server: string | null; 31 | in_use: boolean | null; 32 | } 33 | 34 | interface VersionInfo { 35 | type: "version"; 36 | version_update_type: "major" | "minor" | "patch"; 37 | new_tag: string; 38 | current_version: string; 39 | new_version: string; 40 | } 41 | 42 | interface DigestInfo { 43 | type: "digest"; 44 | local_digests: string[]; 45 | remote_digest: string; 46 | } 47 | 48 | export interface Filters { 49 | onlyInUse: boolean; 50 | registries: string[]; 51 | statuses: ( 52 | | "Major update" 53 | | "Minor update" 54 | | "Patch update" 55 | | "Digest update" 56 | | "Up to date" 57 | | "Unknown" 58 | )[]; 59 | } 60 | -------------------------------------------------------------------------------- /web/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import type { Image } from "./types"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function getDescription(image: Image) { 10 | switch (image.result.has_update) { 11 | case null: 12 | return "Unknown"; 13 | case false: 14 | return "Up to date"; 15 | case true: 16 | if (image.result.info?.type === "version") { 17 | switch (image.result.info.version_update_type) { 18 | case "major": 19 | return "Major update"; 20 | case "minor": 21 | return "Minor update"; 22 | case "patch": 23 | return "Patch update"; 24 | } 25 | } else if (image.result.info?.type === "digest") { 26 | return "Digest update"; 27 | } 28 | return "Unknown"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/App.tsx", "./src/components/**/*.tsx", "./index.liquid"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("tailwindcss-animate")], 8 | safelist: [ 9 | // Generate minimum extra CSS 10 | { 11 | pattern: /bg-(gray|neutral)-(50|200|500)/, 12 | }, 13 | { 14 | pattern: /bg-(gray|neutral)-100/, 15 | variants: ["hover"], 16 | }, 17 | { 18 | pattern: /bg-(gray|neutral)-(400|900|950)/, 19 | variants: ["dark"], 20 | }, 21 | { 22 | pattern: /bg-(gray|neutral)-200/, 23 | variants: ["before", "after"], 24 | }, 25 | { 26 | pattern: /bg-(gray|neutral)-900/, 27 | variants: ["before:dark", "after:dark", "dark", "hover:dark"], 28 | }, 29 | { 30 | pattern: /text-(gray|neutral)-(50|300|200|400)/, 31 | variants: ["dark"], 32 | }, 33 | { 34 | pattern: /text-(gray|neutral)-600/, 35 | variants: ["*", "dark", "hover", "placeholder", "data-[placeholder]"], 36 | }, 37 | { 38 | pattern: /text-(gray|neutral)-400/, 39 | variants: ["*:dark", "dark", "dark:hover", "placeholder:dark", "data-[placeholder]:dark"], 40 | }, 41 | { 42 | pattern: /text-(gray|neutral)-(500|700)/, 43 | }, 44 | { 45 | pattern: /text-(gray|neutral)-950/, 46 | variants: ["dark:group-data-[state=checked]"] 47 | }, 48 | { 49 | pattern: /text-(gray|neutral)-800/, 50 | variants: ["group-data-[hover]"], 51 | }, 52 | { 53 | pattern: /text-(gray|neutral)-200/, 54 | variants: ["group-data-[hover]:dark"], 55 | }, 56 | { 57 | pattern: /divide-(gray|neutral)-900/, 58 | variants: ["dark"], 59 | }, 60 | { 61 | pattern: /border-(gray|neutral)-(600|300|400)/, 62 | }, 63 | { 64 | pattern: /border-(gray|neutral)-(400|700|800|900)/, 65 | variants: ["dark"], 66 | }, 67 | { 68 | pattern: /ring-(gray|neutral)-700/, 69 | }, 70 | { 71 | pattern: /ring-(gray|neutral)-400/, 72 | variants: ["dark"], 73 | }, 74 | ], 75 | }; 76 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: "./", 8 | build: { 9 | rollupOptions: { 10 | // https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943 11 | output: { 12 | entryFileNames: `assets/[name].js`, 13 | chunkFileNames: `assets/[name].js`, 14 | assetFileNames: `assets/[name].[ext]`, 15 | }, 16 | }, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------