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

94 Commits

Author SHA1 Message Date
Sergio
cc785d9910 change 'used by' 2025-09-10 10:49:16 +03:00
Sergio
6ec2d71009 feat: enable upgrading running images 2025-09-10 10:48:24 +03:00
Sergio
4e43abbb9d refactor config 2025-09-10 10:48:09 +03:00
Sergio
c70bd55de8 experimental: used badge 2025-06-19 20:51:41 +03:00
Sergio
4fe070a5a0 refactor: replace unwrap_or with unwrap_or_default 2025-06-19 20:51:41 +03:00
Sergio
adbd999c14 refactor: remove unneeded match that can be replaced by if let 2025-06-19 20:51:41 +03:00
Sergio
ddd514ffa0 feat: show which containers use a specific image 2025-06-19 20:51:41 +03:00
Sergio
eaf2cd7881 chore: remove v2 API endpoints 2025-06-19 20:51:41 +03:00
Sergio
b542f1bac5 chore: bump project version 2025-05-27 14:51:48 +03:00
Sergio
34ae9cb36f fix: ignore empty refresh interval 2025-05-27 14:51:48 +03:00
Sergio
e015afbaca chore: update project version in Cargo.lock
This should have happened automatically when Cargo.toml was updated
2025-05-27 14:51:13 +03:00
Sergio
6dc1030a3b chore: bump project version 2025-05-21 19:30:30 +03:00
Sergio
d2c1651761 docs: update automatic refresh docs to mention TZ configuration 2025-05-21 11:39:41 +03:00
Sergio
8b3cf73f65 docs: add docs for environment variables 2025-05-21 11:34:16 +03:00
Sergio
6d88036914 feat: add timezone support 2025-05-21 11:13:27 +03:00
Sergio
a06266264d refactor: avoid a clone if extra images are empty 2025-05-20 17:46:09 +03:00
Sergio
c260874459 feat: add support for configuring through environment variables 2025-05-20 17:03:36 +03:00
Sergio
3e42ac338a fix: errors in http.rs 2025-05-10 20:55:14 +03:00
Sergio
15784eb4f1 fix: errors in http.rs 2025-05-10 20:52:04 +03:00
Sergio
2623f52a20 fix: handle 502 gracefully
Fixes #104
2025-05-10 20:48:22 +03:00
Sergio
8a5b0555f7 feat: add new filters 2025-05-09 13:19:42 +03:00
Sergio
2e1b0945e0 chore: bump project version 2025-05-09 09:51:16 +03:00
Raphaël C.
c8229d7370 feat: add new filter for in use images to web ui 2025-05-08 20:54:26 +03:00
Sergio
4b3bf9bd8f chore: update gitignore 2025-05-02 19:54:14 +03:00
Sergio
3ac990abce fix: clippy lints 2025-05-02 19:54:14 +03:00
Raphaël Catarino
5ea924c5ad refactor: better data fetching (#100) 2025-05-02 19:12:19 +03:00
Raphaël Catarino
2ac036d353 refactor: remove node and use only bun (#101) 2025-04-26 13:13:30 +03:00
Seow Alex
80a295680d feat: add ability to ignore certain update types (#91) 2025-04-12 09:37:28 +03:00
Sergio
efea81ef39 chore: bump project version 2025-04-10 19:22:19 +03:00
Pavel
d3cb5af225 Fix refresh button when using custom base url (#89) 2025-04-07 07:11:44 +03:00
Sergio
5904c2d2e2 fix: ignore version info when tags are equal
Even though some images had newer digests, they weren't being taken into
consideration when checking for updates. Should resolve #85 (further
testing needed to confirm).
2025-04-06 20:10:05 +03:00
Sergio
674bc3d614 fix: misaligned table columns in CLI
Reported in #85
2025-04-04 16:09:31 +03:00
Seow Alex
e4a07f9810 fix: use default registry for docker.io (#86) 2025-04-03 22:17:50 +03:00
dependabot[bot]
4e0f3c3eb9 chore(deps): bump next from 15.2.3 to 15.2.4 in /docs (#87)
Bumps [next](https://github.com/vercel/next.js) from 15.2.3 to 15.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.2.3...v15.2.4)

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-07 19:26:28 +02:00
Sergio
aa4195f8d6 chore: bump project version 2025-03-07 18:25:00 +02:00
Sergio
1b94629c79 fix: overflowing image references in web UI 2025-03-07 18:01:32 +02:00
Sergio
8cd9cce94e chore: bump project version 2025-03-07 17:38:20 +02:00
Sergio
ddabd8c102 fix: strip hash when parsing image references 2025-03-07 17:37:56 +02:00
Sergio
0b0028ab6d Fix search in docs 2025-03-07 16:40:59 +02:00
Sergio
75509550b1 Fix CI Dockerfile 2025-03-03 11:35:58 +02:00
Sergio
9716d1a351 Fix CI Dockerfile 2025-03-03 11:30:09 +02:00
Sergio
d5a2556768 Fix CI workflows 2025-03-03 11:23:26 +02:00
Sergio
e7f1921620 I'm stupid 2025-03-03 11:14:16 +02:00
Sergio
7ea6ae6de5 Let's try again 2025-03-03 10:31:42 +02:00
Sergio
d7c2e6436c Fix CI workflows 2025-03-03 10:23:08 +02:00
Sergio
fde61ee07d Fix CI workflows 2025-03-03 10:18:11 +02:00
Sergio
c4de3961a0 Optimize CI for docker image 2025-03-03 10:05:18 +02:00
Sergio
404c574c2c Update Rust version in Dockerfile 2025-03-02 13:08:43 +02:00
Sergio
6d4df20f54 Ignore registries before retrieving auth tokens 2025-03-02 13:05:23 +02:00
Sergio
7b3745d095 Fix errors and revert reqwest-middleware to v0.3.3 2025-03-02 13:04:31 +02:00
Sergio
f9aa516da7 Update dependencies. Also fixes a security vulnerability in rustls 2025-03-02 12:45:29 +02:00
Sergio
0f9c5d1466 V3
Many many many changes, honestly just read the release notes
2025-02-28 20:43:49 +02:00
93 changed files with 5281 additions and 1611 deletions

13
.github/actions/build-image/Dockerfile vendored Normal file
View File

@@ -0,0 +1,13 @@
FROM --platform=$BUILDPLATFORM alpine AS builder
ARG TARGETARCH
ARG TARGETOS
COPY binaries/* /
RUN mv cup-$TARGETOS-$TARGETARCH cup
RUN chmod +x cup
FROM scratch
COPY --from=builder /cup /cup
EXPOSE 8000
ENTRYPOINT ["/cup"]

52
.github/actions/build-image/action.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Build Image
inputs:
tags:
description: "Docker image tags"
required: true
gh-token:
description: "Github token"
required: true
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download binaries
uses: actions/download-artifact@v4
with:
path: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/sergi0g/cup
tags: ${{ inputs.tags }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: sergi0g
password: ${{ inputs.gh-token }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./.github/actions/build-image/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -14,13 +14,8 @@ jobs:
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
- name: Install deps
run: cd web && bun install

View File

@@ -2,7 +2,7 @@ name: Deploy github pages
on:
push:
paths:
- "docs/**"
- 'docs/**'
workflow_dispatch:
jobs:
build:
@@ -15,11 +15,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Build

View File

@@ -32,13 +32,8 @@ jobs:
- name: Install cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
- name: Install deps
run: cd web && bun install
@@ -62,38 +57,25 @@ jobs:
cup-linux-arm64
build-image:
needs: get-tag
needs:
- get-tag
- build-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
- uses: ./.github/actions/build-image
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64, linux/arm64
push: true
tags: ghcr.io/sergi0g/cup:${{ needs.get-tag.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ needs.get-tag.outputs.tag }}
gh-token: ${{ secrets.GITHUB_TOKEN }}
nightly-release:
runs-on: ubuntu-latest
needs: [build-binaries, get-tag]
needs:
- get-tag
- build-binaries
- build-image
steps:
- name: Download binaries
uses: actions/download-artifact@v4

View File

@@ -30,13 +30,8 @@ jobs:
- name: Install cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
- name: Install deps
run: cd web && bun install
@@ -60,34 +55,19 @@ jobs:
cup-linux-arm64
build-image:
needs: get-tag
needs:
- get-tag
- build-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
- uses: ./.github/actions/build-image
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64, linux/arm64
push: true
tags: ghcr.io/sergi0g/cup:${{ needs.get-tag.outputs.tag }},ghcr.io/sergi0g/cup:latest
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ needs.get-tag.outputs.tag }}
latest
gh-token: ${{ secrets.GITHUB_TOKEN }}
release:
runs-on: ubuntu-latest
@@ -107,4 +87,4 @@ jobs:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}
name: ${{ needs.get-tag.outputs.tag }}
files: binaries/*
files: binaries/*

View File

@@ -7,7 +7,7 @@ First of all, thanks for taking time to contribute to Cup! This guide will help
Requirements:
- A computer running Linux
- Rust (usually installed from https://rustup.rs/)
- Node.js 22+ and Bun 1+
- Bun 1+
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.
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
@@ -48,4 +48,4 @@ After you're done with that, commit your changes and push them to your branch.
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.
Happy contributing!
Happy contributing!

1235
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
[package]
name = "cup"
version = "3.0.0"
version = "3.4.1"
edition = "2021"
[dependencies]
clap = { version = "4.5.7", features = ["derive"] }
indicatif = { version = "0.17.8", optional = true }
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
xitca-web = { version = "0.5.0", optional = true }
xitca-web = { version = "0.6.2", optional = true, features = ["json"]}
liquid = { version = "0.26.6", optional = true }
bollard = "0.16.1"
bollard = "0.19.0"
once_cell = "1.19.0"
http-auth = { version = "0.1.9", default-features = false }
termsize = { version = "0.1.8", optional = true }
@@ -17,14 +17,16 @@ regex = { version = "1.10.5", default-features = false, features = ["perf"] }
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true }
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] }
futures = "0.3.30"
reqwest-retry = "0.6.1"
reqwest-retry = "0.7.0"
reqwest-middleware = "0.3.3"
rustc-hash = "2.0.0"
http-link = "1.0.1"
itertools = "0.13.0"
itertools = "0.14.0"
serde_json = "1.0.133"
serde = "1.0.215"
tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true }
envy = "0.4.2"
chrono-tz = "0.10.3"
[features]
default = ["server", "cli"]

View File

@@ -1,21 +1,21 @@
### Build UI ###
FROM node:20 AS web
FROM oven/bun:1-alpine AS web
# Install bun
RUN curl -fsSL https://bun.sh/install | bash
# Copy web folder
COPY ./web /web
# Copy package.json and lockfile from web
WORKDIR /web
COPY ./web/package.json ./web/bun.lock ./
# Install requirements
RUN ~/.bun/bin/bun install
RUN bun install
# Copy web folder
COPY ./web .
# Build frontend
RUN ~/.bun/bin/bun run build
RUN bun run build
### Build Cup ###
FROM rust:1.80.1-alpine AS build
FROM rust:1-alpine AS build
# Requirements
RUN apk add musl-dev
@@ -39,4 +39,5 @@ FROM scratch
# Copy binary
COPY --from=build /cup/target/release/cup /cup
ENTRYPOINT ["/cup"]
EXPOSE 8000
ENTRYPOINT ["/cup"]

View File

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

View File

@@ -14,6 +14,16 @@
"type": "boolean",
"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."
},
"ignore_update_type": {
"type": "string",
"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.",
"enum": [
"none",
"major",
"minor",
"patch"
]
},
"images": {
"type": "object",
"description": "Configuration options for specific images",
@@ -36,7 +46,7 @@
},
"refresh_interval": {
"type": "string",
"description": "The interval at which Cup should check for updates. Must be a valid cron expression. Reference: https://github.com/Hexagon/croner-rust#pattern",
"description": "The interval at which Cup should check for updates. Must be a valid cron expression. Seconds are not optional. Reference: https://github.com/Hexagon/croner-rust#pattern",
"minLength": 11
},
"registries": {
@@ -59,8 +69,8 @@
}
},
"socket": {
"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.",
"type": "string",
"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.",
"minLength": 1
},
"servers": {
@@ -73,8 +83,8 @@
"minProperties": 1
},
"theme": {
"description": "The theme used by the web UI",
"type": "string",
"description": "The theme used by the web UI",
"enum": [
"default",
"blue"

1661
docs/bun.lock Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build && pagefind --site out --output-path out/_pagefind",
"start": "next start",
"lint": "next lint",
"fmt": "bun prettier --write ."
@@ -12,7 +12,7 @@
"dependencies": {
"@tabler/icons-react": "^3.29.0",
"geist": "^1.3.1",
"next": "15.1.5",
"next": "15.2.4",
"nextra": "^4.1.0",
"nextra-theme-docs": "^4.1.0",
"react": "^19.0.0",
@@ -21,16 +21,16 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.0.1",
"@types/node": "^22.10.7",
"@types/bun": "^1.2.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"eslint": "^9.18.0",
"eslint-config-next": "15.1.5",
"pagefind": "^1.3.0",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.1",
"typescript": "^5.7.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -1,15 +0,0 @@
import React from "react";
export function GitHubIcon({ className }: { className?: string | undefined }) {
return (
<svg
width="24"
height="24"
fill="currentColor"
viewBox="3 3 18 18"
className={className}
>
<path d="M12 3C7.0275 3 3 7.12937 3 12.2276C3 16.3109 5.57625 19.7597 9.15374 20.9824C9.60374 21.0631 9.77249 20.7863 9.77249 20.5441C9.77249 20.3249 9.76125 19.5982 9.76125 18.8254C7.5 19.2522 6.915 18.2602 6.735 17.7412C6.63375 17.4759 6.19499 16.6569 5.8125 16.4378C5.4975 16.2647 5.0475 15.838 5.80124 15.8264C6.51 15.8149 7.01625 16.4954 7.18499 16.7723C7.99499 18.1679 9.28875 17.7758 9.80625 17.5335C9.885 16.9337 10.1212 16.53 10.38 16.2993C8.3775 16.0687 6.285 15.2728 6.285 11.7432C6.285 10.7397 6.63375 9.9092 7.20749 9.26326C7.1175 9.03257 6.8025 8.08674 7.2975 6.81794C7.2975 6.81794 8.05125 6.57571 9.77249 7.76377C10.4925 7.55615 11.2575 7.45234 12.0225 7.45234C12.7875 7.45234 13.5525 7.55615 14.2725 7.76377C15.9937 6.56418 16.7475 6.81794 16.7475 6.81794C17.2424 8.08674 16.9275 9.03257 16.8375 9.26326C17.4113 9.9092 17.76 10.7281 17.76 11.7432C17.76 15.2843 15.6563 16.0687 13.6537 16.2993C13.98 16.5877 14.2613 17.1414 14.2613 18.0065C14.2613 19.2407 14.25 20.2326 14.25 20.5441C14.25 20.7863 14.4188 21.0746 14.8688 20.9824C16.6554 20.364 18.2079 19.1866 19.3078 17.6162C20.4077 16.0457 20.9995 14.1611 21 12.2276C21 7.12937 16.9725 3 12 3Z"></path>
</svg>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -10,12 +10,12 @@ export function Card({
description: string;
}) {
return (
<div>
<Icon className="text-black size-7 dark:text-white inline mr-2" />
<div className="p-4 bg-white dark:bg-black group">
<Icon className="text-black size-7 group-hover:size-9 dark:text-white inline mr-2 transition-[width,height] duration-200" />
<span className="align-middle text-2xl font-bold text-black dark:text-white">
{name}
</span>
<p className="text-2xl font-semibold text-neutral-500 dark:text-neutral-500">
<p className="text-xl font-semibold text-neutral-500 dark:text-neutral-500">
{description}
</p>
</div>

View File

@@ -1,28 +0,0 @@
"use client";
import { IconCopy, IconCopyCheck } from "@tabler/icons-react";
import { useState } from "react";
export default function CopyableCode({ children }: { children: string }) {
const [success, setSuccess] = useState(false);
const handleClick = () => {
navigator.clipboard.writeText(children);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
};
return (
<div className="relative rounded-md xl:w-auto">
<button
className="hover:bg-black/10 dark:hover:bg-black/60 flex w-full items-center justify-center gap-4 rounded-md border border-black/10 bg-black/5 px-8 py-3 font-mono text-sm font-medium text-black/70 transition-colors duration-200 md:px-10 md:py-3 md:text-base md:leading-6 dark:border-white/15 dark:bg-black dark:text-gray-300 backdrop-blur-md"
onClick={handleClick}
>
{children}
{success ? (
<IconCopyCheck className="stroke-black/40 dark:stroke-white/50" />
) : (
<IconCopy className="stroke-black/40 dark:stroke-white/50" />
)}
</button>
</div>
);
}

View File

@@ -8,7 +8,7 @@ export function GridPattern() {
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 bottom-0 left-0 right-0 top-0 -z-10 h-full w-full stroke-neutral-200 dark:stroke-neutral-600/30"
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"
>
<defs>
<pattern
@@ -22,7 +22,6 @@ export function GridPattern() {
<path
d={`M.5 ${SIZE}V.5H${SIZE}`}
fill="none"
strokeDasharray={"4 2"}
/>
</pattern>
</defs>

View File

@@ -1,28 +0,0 @@
import { ReactNode } from "react";
import { GradientText } from "./GradientText";
export function Section({
title,
className,
children,
}: {
title: string;
className: string;
children: ReactNode;
}) {
return (
<div className="border-t border-t-neutral-300 bg-neutral-50 py-32 dark:border-t-neutral-600/30 dark:bg-neutral-950">
<div className="mx-auto w-full max-w-screen-xl">
<GradientText
text={title}
className="mx-auto mb-20 w-fit text-center text-4xl font-bold tracking-tighter"
innerClassName={className}
blur={12}
/>
<div className="m-2 grid w-full auto-cols-fr gap-20 lg:grid-cols-3">
{children}
</div>
</div>
</div>
);
}

View File

@@ -1,29 +1,28 @@
import React from "react";
import "./styles.css"
import "./styles.css";
import CopyableCode from "../CopyableCode";
import { Browser } from "../Browser";
import { Card } from "../Card";
import {
IconAdjustments,
IconArrowRight,
IconBarrierBlockOff,
IconBolt,
IconBraces,
IconDevices,
IconFeather,
IconLockCheck,
IconGitMerge,
IconPuzzle,
IconServer,
IconTerminal,
} from "@tabler/icons-react";
import { GitHubIcon } from "nextra/icons";
import { GridPattern } from "../GridPattern";
import { GradientText } from "../GradientText";
import { Section } from "../Section";
import { Steps } from "nextra/components";
import Link from "next/link";
export default async function Home() {
return (
<>
<div className="relative home">
<div className="relative home bg-radial-[ellipse_at_center] from-transparent from-20% to-white dark:to-black">
<GridPattern />
<div className="px-4 pt-16 pb-8 sm:pt-24 lg:px-8">
<div className="flex w-full flex-col items-center justify-between">
@@ -37,7 +36,7 @@ export default async function Home() {
blur={30}
/>
</h1>
<h3 className="mx-auto mt-6 max-w-3xl text-center text-xl leading-tight font-medium text-gray-400">
<h3 className="mx-auto mt-6 max-w-3xl text-center text-xl leading-tight font-medium text-neutral-500 dark:text-neutral-400">
Cup is a small utility with a big impact. Simplify your
container management workflow with fast and efficient update
checking, a full-featured CLI and web interface, and more.
@@ -54,7 +53,7 @@ export default async function Home() {
<a
href="https://github.com/sergi0g/cup"
target="_blank"
className="hide-focus h-full text-nowrap border border-neutral-400 transition-colors duration-200 ease-in-out hover:border-neutral-600 focus:border-neutral-600 dark:border-neutral-600 hover:dark:border-neutral-400 hover:dark:shadow-sm hover:dark:shadow-neutral-600 focus:dark:border-neutral-400"
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"
>
Star on GitHub
<GitHubIcon className="ml-auto size-4 md:size-5" />
@@ -66,68 +65,49 @@ export default async function Home() {
<Browser />
</div>
</div>
<Section
title="Powerful at its core."
className="bg-gradient-to-r from-red-500 to-amber-500"
>
<Card
name="100% Safe Code"
icon={IconLockCheck}
description="Built with safe Rust and Typescript to ensure security and reliability."
/>
<Card
name="Lightning Fast Performance"
icon={IconBolt}
description="Heavily optimized to squeeze out every last drop of performance. Each release is extensively benchmarked and profiled so that you'll never have to stare at a loading spinner for long."
/>
<Card
name="Lightweight"
icon={IconFeather}
description="No runtimes or libraries are needed. All you need is the 5.1 MB static binary that works out of the box on any system."
/>
</Section>
<Section
title="Efficient, yet flexible."
className="bg-gradient-to-r from-blue-500 to-indigo-500"
>
<Card
name="JSON output"
description="Connect Cup to your favorite intergrations with JSON output for the CLI and an API for the server. Now go make that cool dashboard you've been dreaming of!"
icon={IconBraces}
/>
<Card
name="Both CLI and web interface"
description="Whether you prefer the command line or the web, Cup runs wherever you choose."
icon={IconDevices}
/>
<Card
name="Configurable"
description="The simple configuration file provides you with all the tools you need to specify a custom Docker socket, manage registry connection options, choose a theme for the web interface and more."
icon={IconAdjustments}
/>
</Section>
<div className="relative py-24 border-t border-t-neutral-300 dark:border-t-neutral-600/30 text-black dark:text-white">
<GridPattern />
<div className="mx-auto flex w-full max-w-screen-xl flex-col items-center">
<p className="mb-8 text-center text-3xl font-bold">
Still not convinced? Try it out now!
</p>
<div>
<Steps>
<h3 className="mb-2">Open a terminal and run</h3>
<CopyableCode>
docker run --rm -t -v /var/run/docker.sock:/var/run/docker.sock
-p 8000:8000 ghcr.io/sergi0g/cup serve
</CopyableCode>
<h3 className="mb-2">Open the dashboard in your browser</h3>
<p>
Visit{" "}
<a href="http://localhost:8000" className="underline">
http://localhost:8000
</a>{" "}
in your browser to try it out!
</p>
</Steps>
<div className="bg-white dark:bg-black py-12 px-8 w-full">
<div className="flex h-full w-full items-center justify-center">
<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">
<Card
name="Built for speed."
icon={IconBolt}
description="Cup is written in Rust and every release goes through extensive profiling to squeeze out every last drop of performance."
/>
<Card
name="Configurable."
icon={IconAdjustments}
description="Make Cup yours with the extensive configuration options available. Customize and tailor it to your needs."
/>
<Card
name="Extend it."
icon={IconPuzzle}
description="JSON output enables you to connect Cup with your favorite integrations, build automations and more."
/>
<Card
name="CLI available."
icon={IconTerminal}
description="Do you like terminals? Cup has a CLI. Check for updates quickly without spinning up a server."
/>
<Card
name="Multiple servers."
icon={IconServer}
description="Run multiple Cup instances and effortlessly check on them through one web interface."
/>
<Card
name="Unstoppable."
icon={IconBarrierBlockOff}
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."
/>
<Card
name="Lightweight."
icon={IconFeather}
description="No need for a powerful server and endless storage. The tiny 5.4 MB binary won't hog your CPU and memory."
/>
<Card
name="Open source."
icon={IconGitMerge}
description="All source code is publicly available in our GitHub repository. We're looking for contributors!"
/>
</div>
</div>
</div>

View File

@@ -20,7 +20,7 @@ const logo = (
);
const navbar = (
<Navbar logo={logo} projectLink="https://github.com/sergi0g/cup">
<Navbar logo={logo} projectLink="https://github.com/sergi0g/cup" chatLink="https://discord.gg/jmh5ctzwNG">
<ThemeSwitch lite className="cursor-pointer" />
</Navbar>
);
@@ -45,7 +45,7 @@ export default async function RootLayout({
navbar={navbar}
pageMap={await getPageMap()}
footer={footer}
docsRepositoryBase="https://github.com/sergi0g/cup"
docsRepositoryBase="https://github.com/sergi0g/cup/blob/main/docs"
>
<div>{children}</div>
</Layout>

View File

@@ -10,7 +10,6 @@ const toc: Heading[] = [];
export const metadata: NextraMetadata = {
title: "Cup - The easiest way to manage your container updates",
description: "Simple, fast, efficient Docker image update checking",
filePath: "",
};
export default function Page() {

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ services:
homepage.widget.mappings[1].field.metrics: up_to_date
homepage.widget.mappings[1].format: number
homepage.widget.mappings[2].label: Updates
homepage.widget.mappings[2].field.metrics: update_available
homepage.widget.mappings[2].field.metrics: updates_available
homepage.widget.mappings[2].format: number
```
@@ -64,7 +64,7 @@ widget:
label: Up to date
format: number
- field:
metrics: update_available
metrics: updates_available
label: Available updates
format: number
- field:

View File

@@ -0,0 +1,12 @@
# Agent mode
If you'd like to have only the server API exposed without the dashboard, you can run Cup in agent mode.
Modify your config like this:
```jsonc
{
"agent": true
// Other options
}
```

View File

@@ -4,7 +4,7 @@ import { Callout } from "nextra/components";
Some registries (or specific images) may require you to be authenticated. For those, you can modify `cup.json` like this:
```json
```jsonc
{
"registries": {
"<YOUR_REGISTRY_DOMAIN_1>": {

View File

@@ -0,0 +1,18 @@
import { Callout } from "nextra/components";
# Automatic refresh
Cup can automatically refresh the results when running in server mode. Simply add this to your config:
```jsonc
{
"refresh_interval": "0 */30 * * * *", // Check twice an hour
// Other options
}
```
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).
<Callout>
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).
</Callout>

View File

@@ -0,0 +1,22 @@
# Ignored registries
If you want to skip checking images from some registries, you can modify your config like this:
```jsonc
{
"registries": {
"<SOME_REGISTRY_DOMAIN_1>": {
"ignore": true
// Other options
},
"<SOME_REGISTRY_DOMAIN_2>" {
"ignore": false
// Other options
},
// ...
}
// Other options
}
```
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.

View File

@@ -0,0 +1,23 @@
import { Callout } from "nextra/components";
# Ignored update types
To ignore certain update types, you can modify your config like this:
```jsonc
{
"ignore_update_type": "minor"
}
```
Available options are:
- `none`: Do not ignore any update types (default).
- `major`: Ignore major updates.
- `minor`: Ignore major and minor updates.
- `patch`: Ignore major, minor and patch updates.
<Callout emoji="⚠️">
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.
</Callout>

View File

@@ -0,0 +1,35 @@
# Include/Exclude images
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:
```jsonc
{
"images": {
"exclude": [
"ghcr.io/immich-app/immich-machine-learning",
"postgres:15"
]
// ...
}
// Other options
}
```
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?).
If you want Cup to always check some extra images that aren't available locally, you can modify your config like this:
```jsonc
{
"images": {
"extra": [
"mysql:8.0",
"nextcloud:30"
]
// ...
}
// Other options
}
```
Note that you must specify images with version tags, otherwise Cup will exit with an error!

View File

@@ -8,7 +8,7 @@ import {
IconLockOpen,
IconKey,
IconPlug,
IconServer
IconServer,
} from "@tabler/icons-react";
# Configuration
@@ -71,12 +71,21 @@ Here's a full example:
```json
{
"authentication": {
"ghcr.io": "<YOUR_TOKEN_HERE>",
"registry-1.docker.io": "<YOUR_TOKEN_HERE>"
"$schema": "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json",
"version": 3,
"images": {
"exclude": ["ghcr.io/immich-app/immich-machine-learning"],
"extra": ["ghcr.io/sergi0g/cup:v3.0.0"]
},
"theme": "blue",
"insecure_registries": ["localhost:5000", "my-insecure-registry.example.com"]
"registries": {
"myregistry.com": {
"authentication": "<YOUR_TOKEN_HERE>"
}
},
"servers": {
"Raspberry Pi": "https://server.local:8000"
},
"theme": "blue"
}
```
@@ -100,3 +109,36 @@ $ docker run -tv /var/run/docker.sock:/var/run/docker.sock -v /home/sergio/.conf
```
</Steps>
## Environment Variables
Want to make a quick change without editing your `config.json`? Cup also supports some configuration options from environment variables.
Here are the ones currently available:
- `CUP_AGENT` - Agent mode
- `CUP_IGNORE_UPDATE_TYPE` - Ignoring specific update types
- `CUP_REFRESH_INTERVAL` - Automatic refresh
- `CUP_SOCKET` - Socket
- `CUP_THEME` - Theme
Refer to the configuration page for more information on each of these.
Here's an example of a Docker Compose file using them:
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup:latest
command: serve
ports:
- 8000:8000
environment:
- CUP_AGENT: true
- CUP_IGNORE_UPDATE_TYPE: major
- CUP_REFRESH_INTERVAL: "0 */30 * * * *"
- CUP_SOCKET: tcp://localhost:2375
- CUP_THEME: blue
```
<Callout>
Heads up!
Any configuration option you set with environment variables **always** overrides anything in your `cup.json`.
</Callout>

View File

@@ -8,7 +8,7 @@ To solve this problem, you can specify exceptions in your `cup.json`.
Here's what it looks like:
```json
```jsonc
{
"registries": {
"<INSECURE_REGISTRY_1>": {

View File

@@ -4,7 +4,7 @@ Besides checking for local image updates, you might want to be able to view upda
Just add something like this to your config:
```json
```jsonc
{
"servers": {
"Cool server 1": "http://your-other-server-running-cup:8000",

View File

@@ -1,8 +1,8 @@
# Socket
# Custom socket
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:
```json
```jsonc
{
"socket": "/run/user/1000/podman/podman.sock"
// Other options
@@ -11,7 +11,7 @@ If you need to specify a custom Docker socket (e.g. because you're using Podman)
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):
```json
```jsonc
{
"socket": "tcp://localhost:2375"
// Other options

View File

@@ -21,7 +21,7 @@ Available options are `default` and `blue`.
Here's an example:
```json
```jsonc
{
"theme": "blue"
// Other options

View File

@@ -15,7 +15,7 @@ Cup is a lightweight alternative to [What's up Docker?](https://github.com/getwu
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/getwud/wud) which would always use it up.
- Beautiful CLI and web interface for checking on your containers any time.
- The binary is tiny! At the time of writing it's just 5.1 MB. No more pulling 100+ MB docker images for a such a simple program.
- The binary is tiny! At the time of writing it's just 5.4 MB. No more pulling 100+ MB docker images for a such a simple program.
- JSON output for both the CLI and web interface so you can connect Cup to integrations. It's easy to parse and makes webhooks and pretty dashboards simple to set up!
# Installation

View File

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

View File

@@ -12,36 +12,52 @@ Cup's CLI provides the `cup check` command.
```ansi
$ cup check

mysql:8.0 Major update
node:20 Major update
postgres:16-alpine Major update
rust:1.80.1-alpine Minor update
redis:7.4.0 Patch update
nginx:alpine Update available
redis:alpine Update available
ubuntu:latest Update available
node:iron Up to date
2fauth/2fauth:latest Up to date
c1982/sdns:latest Up to date
registry.acme.com/acme-server:latest Unknown
INFO ✨ Checked 58 images in 3772ms
✓ Done!
~ Local images
╭─────────────────────────────────────────┬──────────────────────────────────┬─────────╮
│Reference │Status │Time (ms)│
├─────────────────────────────────────────┼──────────────────────────────────┼─────────┤
│postgres:15-alpine │Major update (15 → 17) │788 │
│ghcr.io/immich-app/immich-server:v1.118.2│Minor update (1.118.2 → 1.127.0) │2294 │
│ollama/ollama:0.4.1 │Minor update (0.4.1 → 0.5.12) │533 │
│adguard/adguardhome:v0.107.52 │Patch update (0.107.52 → 0.107.57)│1738 │
│jc21/nginx-proxy-manager:latest │Up to date │583 │
│louislam/uptime-kuma:1 │Up to date │793 │
│moby/buildkit:buildx-stable-1 │Up to date │600 │
│tecnativa/docker-socket-proxy:latest │Up to date │564 │
ubuntu:latest │Up to date │585 │
│wagoodman/dive:latest │Up to date │585 │
│rolebot:latest │Unknown │174 │
╰─────────────────────────────────────────┴──────────────────────────────────┴─────────╯
 INFO ✨ Checked 11 images in 8312ms
```
### Check for updates to specific images
```ansi
$ cup check node:latest
node:latest Update available
INFO ✨ Checked 1 images in 1310ms
$ cup check node:latest
✓ Done!
~ Local images
╭───────────┬────────────────┬─────────╮
│Reference │Status │Time (ms)│
├───────────┼────────────────┼─────────┤
│node:latest│Update available│788 │
╰───────────┴────────────────┴─────────╯
 INFO ✨ Checked 1 images in 310ms
```
```ansi
$ cup check nextcloud:30 postgres:14 mysql:8.0
nextcloud:30 Update available
postgres:14 Update available
mysql:8.0 Up to date
INFO ✨ Checked 3 images in 1769ms
✓ Done!
~ Local images
╭────────────┬────────────────────────┬─────────╮
│Reference │Status │Time (ms)│
├────────────┼────────────────────────┼─────────┤
│postgres:14 │Major update (14 → 17) │195 │
│mysql:8.0 │Major update (8.0 → 9.2)│382 │
│nextcloud:30│Up to date │585 │
╰────────────┴────────────────────────┴─────────╯
 INFO ✨ Checked 3 images in 769ms
```
## Enable icons
@@ -62,7 +78,8 @@ $ cup check -r
```
<Callout emoji="⚠️">
When parsing Cup's output, capture only `stdout`, otherwise you might not get valid JSON (if there are warnings)
When parsing Cup's output, capture only `stdout`, otherwise you might not get
valid JSON (if there are warnings)
</Callout>
## Usage with Docker

View File

@@ -7,7 +7,7 @@ import { Cards } from "nextra/components";
# Usage
You can use Cup in 2 different ways. As a CLI or as a server. You can learn more about each mode in its corresponding page
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
<Cards>
<Cards.Card icon={<IconTerminal />} title="CLI" href="/docs/usage/cli" />

View File

@@ -8,13 +8,13 @@ The server provides the `cup serve` command.
```ansi
$ cup serve
INFO Starting server, please wait...
INFO ✨ Checked 8 images in 8862ms
INFO Ready to start!
HTTP GET / 200 in 0ms
HTTP GET /assets/index.js 200 in 3ms
HTTP GET /assets/index.css 200 in 0ms
HTTP GET /api/v3/json 200 in 0ms
 INFO Starting server, please wait...
 INFO ✨ Checked 8 images in 8862ms
 INFO Ready to start!
 HTTP GET / 200 in 0ms
 HTTP GET /assets/index.js 200 in 3ms
 HTTP GET /assets/index.css 200 in 0ms
 HTTP GET /api/v3/json 200 in 0ms
```
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.)
@@ -29,13 +29,13 @@ Pass the `-p` argument with the port you want to use
```ansi
$ cup serve -p 9000
INFO Starting server, please wait...
INFO ✨ Checked 8 images in 8862ms
INFO Ready to start!
HTTP GET / 200 in 0ms
HTTP GET /assets/index.js 200 in 3ms
HTTP GET /assets/index.css 200 in 0ms
HTTP GET /api/v3/json 200 in 0ms
 INFO Starting server, please wait...
 INFO ✨ Checked 8 images in 8862ms
 INFO Ready to start!
 HTTP GET / 200 in 0ms
 HTTP GET /assets/index.js 200 in 3ms
 HTTP GET /assets/index.css 200 in 0ms
 HTTP GET /api/v3/json 200 in 0ms
```
## Usage with Docker

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -3,7 +3,7 @@ use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
docker::get_images_from_docker_daemon,
docker::{get_images_from_docker_daemon, get_in_use_images},
http::Client,
registry::{check_auth, get_token},
structs::{image::Image, update::Update},
@@ -17,7 +17,7 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
let handles: Vec<_> = ctx.config.servers
.iter()
.map(|(name, url)| async {
.map(|(name, url)| async move {
let base_url = if url.starts_with("http://") || url.starts_with("https://") {
format!("{}/api/v3/", url.trim_end_matches('/'))
} else {
@@ -26,10 +26,10 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
let json_url = base_url.clone() + "json";
if refresh {
let refresh_url = base_url + "refresh";
match client.get(&(&refresh_url), vec![], false).await {
match client.get(&refresh_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}",refresh_url,response.status()));
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status()));
return Vec::new();
}
},
@@ -40,13 +40,14 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
}
}
match client.get(&json_url, vec![], false).await {
match client.get(&json_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}",json_url,response.status()));
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}", json_url, response.status()));
return Vec::new();
}
let json = parse_json(&get_response_body(response).await);
ctx.logger.debug(format!("JSON response for {}: {}", name, json));
if let Some(updates) = json["images"].as_array() {
let mut server_updates: Vec<Update> = updates
.iter()
@@ -57,6 +58,7 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
update.server = Some(name.clone());
update.status = update.get_status();
}
ctx.logger.debug(format!("Updates for {}: {:#?}", name, server_updates));
return server_updates;
}
@@ -78,21 +80,44 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
}
/// Returns a list of updates for all images passed in.
/// TODO: Completely rewrite this and make nothing is missed
pub async fn get_updates(
references: &Option<Vec<String>>,
references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value
refresh: bool,
ctx: &Context,
) -> Vec<Update> {
let client = Client::new(ctx);
// Merge references argument with references from config
let all_references = match &references {
Some(refs) => {
if !ctx.config.extra_images.is_empty() {
refs.clone().extend_from_slice(&ctx.config.extra_images);
}
refs.clone().extend_from_slice(&ctx.config.images.iter().filter(|(_, cfg)| cfg.include).map(|(reference, _)| reference).cloned().collect::<Vec<String>>());
refs
}
None => &ctx.config.extra_images,
};
// Get local images
ctx.logger.debug("Retrieving images to be checked");
let mut images = get_images_from_docker_daemon(ctx, references).await;
let in_use_images = get_in_use_images(ctx).await;
ctx.logger
.debug(format!("Found {} images in use", in_use_images.len()));
// Complete in_use field
images.iter_mut().for_each(|image| {
if let Some(images) = in_use_images.get(&image.reference) {
image.used_by = images.clone()
}
});
// Add extra images from references
if let Some(refs) = references {
if !all_references.is_empty() {
let image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect();
let extra = refs
let extra = all_references
.iter()
.filter(|&reference| !image_refs.contains(reference))
.map(|reference| Image::from_reference(reference))
@@ -118,6 +143,10 @@ pub async fn get_updates(
.iter()
.map(|image| &image.parts.registry)
.unique()
.filter(|&registry| match ctx.config.registries.get(registry) {
Some(config) => !config.ignore,
None => true,
})
.collect::<Vec<&String>>();
// Create request client. All network requests share the same client for better performance.
@@ -136,7 +165,7 @@ pub async fn get_updates(
// Retrieve an authentication token (if required) for each registry.
let mut tokens: FxHashMap<&str, Option<String>> = FxHashMap::default();
for registry in registries {
for registry in registries.clone() {
let credentials = if let Some(registry_config) = ctx.config.registries.get(registry) {
&registry_config.authentication
} else {
@@ -161,24 +190,11 @@ pub async fn get_updates(
ctx.logger.debug(format!("Tokens: {:?}", tokens));
let ignored_registries = ctx
.config
.registries
.iter()
.filter_map(|(registry, registry_config)| {
if registry_config.ignore {
Some(registry)
} else {
None
}
})
.collect::<Vec<&String>>();
let mut handles = Vec::with_capacity(images.len());
// Loop through images check for updates
for image in &images {
let is_ignored = ignored_registries.contains(&&image.parts.registry)
let is_ignored = !registries.contains(&&image.parts.registry)
|| ctx
.config
.images
@@ -193,7 +209,9 @@ pub async fn get_updates(
}
// Await all the futures
let images = join_all(handles).await;
let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect();
updates.extend_from_slice(&remote_updates);
updates
}

View File

@@ -1,15 +1,23 @@
use std::path::PathBuf;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde::Deserializer;
use std::env;
use std::mem;
use std::path::PathBuf;
use crate::error;
// 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.
macro_rules! swap {
($a:expr, $b:expr) => {
mem::swap(&mut $a, &mut $b)
};
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
#[serde(rename = "default")]
Default,
#[serde(rename = "blue")]
Blue,
}
@@ -19,6 +27,21 @@ impl Default for Theme {
}
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateType {
None,
Major,
Minor,
Patch,
}
impl Default for UpdateType {
fn default() -> Self {
Self::None
}
}
#[derive(Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
#[serde(default)]
@@ -28,11 +51,21 @@ pub struct RegistryConfig {
pub ignore: bool,
}
#[derive(Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TagType {
#[default]
Standard,
Extended
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
pub struct ImageConfig {
pub extra: Vec<String>,
pub exclude: Vec<String>,
pub include: bool, // Takes precedence over extra_images and excluded_images
pub tag_type: TagType,
pub ignore: UpdateType
}
#[derive(Clone, Deserialize)]
@@ -40,7 +73,10 @@ pub struct ImageConfig {
pub struct Config {
version: u8,
pub agent: bool,
pub images: ImageConfig,
pub images: FxHashMap<String, ImageConfig>,
pub extra_images: Vec<String>, // These two are here for convenience, using `images` for this purpose should also work.
pub excluded_images: Vec<String>, // Takes precedence over extra_images
#[serde(deserialize_with = "empty_as_none")]
pub refresh_interval: Option<String>,
pub registries: FxHashMap<String, RegistryConfig>,
pub servers: FxHashMap<String, String>,
@@ -48,12 +84,15 @@ pub struct Config {
pub theme: Theme,
}
// TODO: Add helper methods that abstact away complex logic (i.e. functions that return all excluded images, extra images, etc based on the precedence rules set)
impl Config {
pub fn new() -> Self {
Self {
version: 3,
agent: false,
images: ImageConfig::default(),
images: FxHashMap::default(),
extra_images: Vec::new(),
excluded_images: Vec::new(),
refresh_interval: None,
registries: FxHashMap::default(),
servers: FxHashMap::default(),
@@ -62,8 +101,41 @@ impl Config {
}
}
/// Loads file and env config and merges them
pub fn load(&mut self, path: Option<PathBuf>) -> Self {
let mut config = self.load_file(path);
// Get environment variables with CUP_ prefix
let env_vars: FxHashMap<String, String> =
env::vars().filter(|(k, _)| k.starts_with("CUP_")).collect();
if !env_vars.is_empty() {
if let Ok(mut cfg) = envy::prefixed("CUP_").from_env::<Config>() {
// If we have environment variables, override config.json options
for (key, _) in env_vars {
match key.as_str() {
"CUP_AGENT" => config.agent = cfg.agent,
#[rustfmt::skip]
"CUP_REFRESH_INTERVAL" => swap!(config.refresh_interval, cfg.refresh_interval),
"CUP_SOCKET" => swap!(config.socket, cfg.socket),
"CUP_THEME" => swap!(config.theme, cfg.theme),
"CUP_EXTRA_IMAGES" => swap!(config.extra_images, cfg.extra_images),
"CUP_EXCLUDED_IMAGES" => swap!(config.excluded_images, cfg.excluded_images),
// 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.
// "CUP_IMAGES" => swap!(config.images, cfg.images),
// "CUP_REGISTRIES" => swap!(config.registries, cfg.registries),
// "CUP_SERVERS" => swap!(config.servers, cfg.servers),
_ => (), // Maybe print a warning if other CUP_ variables are detected
}
}
}
}
config
}
/// Reads the config from the file path provided and returns the parsed result.
pub fn load(&self, path: Option<PathBuf>) -> Self {
fn load_file(&self, path: Option<PathBuf>) -> Self {
let raw_config = match &path {
Some(path) => std::fs::read_to_string(path),
None => return Self::new(), // Empty config
@@ -77,13 +149,13 @@ impl Config {
self.parse(&raw_config.unwrap()) // We can safely unwrap here
}
/// Parses and validates the config.
pub fn parse(&self, raw_config: &str) -> Self {
fn parse(&self, raw_config: &str) -> Self {
let config: Self = match serde_json::from_str(raw_config) {
Ok(config) => config,
Err(e) => error!("Unexpected error occured while parsing config: {}", e),
};
if config.version != 3 {
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`.")
if config.version != 4 {
error!("You are trying to run Cup with an incompatible config file! Please migrate your config file to the version 4, or if you have already done so, add a `version` key with the value `4`.")
}
config
}
@@ -94,3 +166,15 @@ impl Default for Config {
Self::new()
}
}
fn empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
Ok(Some(s))
}
}

View File

@@ -1,8 +1,26 @@
use bollard::{models::ImageInspect, ClientVersion, Docker};
use bollard::{
models::ImageInspect,
query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, InspectContainerOptions,
ListContainersOptionsBuilder, ListImagesOptions, ListServicesOptions,
RemoveContainerOptions, RenameContainerOptions, StartContainerOptions,
StopContainerOptions,
},
secret::{ContainerCreateBody, CreateImageInfo},
ClientVersion, Docker,
};
use futures::future::join_all;
use futures::{future::join_all, StreamExt};
use rustc_hash::FxHashMap;
use crate::{error, structs::image::Image, Context};
use crate::{
error,
structs::{
image::Image,
update::{Update, UpdateInfo},
},
Context,
};
fn create_docker_client(socket: Option<&str>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -42,7 +60,26 @@ pub async fn get_images_from_docker_daemon(
references: &Option<Vec<String>>,
) -> Vec<Image> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
match references {
let mut swarm_images = match client.list_services(None::<ListServicesOptions>).await {
Ok(services) => services
.iter()
.filter_map(|service| match &service.spec {
Some(service_spec) => match &service_spec.task_template {
Some(task_spec) => match &task_spec.container_spec {
Some(container_spec) => match &container_spec.image {
Some(image) => Image::from_inspect_data(ctx, image),
None => None,
},
None => None,
},
None => None,
},
None => None,
})
.collect(),
Err(_) => Vec::new(),
};
let mut local_images = match references {
Some(refs) => {
let mut inspect_handles = Vec::with_capacity(refs.len());
for reference in refs {
@@ -56,11 +93,11 @@ pub async fn get_images_from_docker_daemon(
.collect();
inspects
.iter()
.filter_map(|inspect| Image::from_inspect_data(inspect.clone()))
.filter_map(|inspect| Image::from_inspect_data(ctx, inspect.clone()))
.collect()
}
None => {
let images = match client.list_images::<String>(None).await {
let images = match client.list_images(None::<ListImagesOptions>).await {
Ok(images) => images,
Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e)
@@ -68,8 +105,247 @@ pub async fn get_images_from_docker_daemon(
};
images
.iter()
.filter_map(|image| Image::from_inspect_data(image.clone()))
.collect()
.filter_map(|image| Image::from_inspect_data(ctx, image.clone()))
.collect::<Vec<Image>>()
}
};
local_images.append(&mut swarm_images);
local_images
}
pub async fn get_in_use_images(ctx: &Context) -> FxHashMap<String, Vec<String>> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let options = ListContainersOptionsBuilder::new().all(true).build();
let containers = match client.list_containers(Some(options)).await {
Ok(containers) => containers,
Err(e) => {
error!("Failed to retrieve list of containers available!\n{}", e)
}
};
let mut result: FxHashMap<String, Vec<String>> = FxHashMap::default();
containers
.iter()
.filter(|container| container.image.is_some())
.for_each(|container| {
let reference = match &container.image {
Some(image) => {
if image.contains(":") {
image.clone()
} else {
format!("{image}:latest")
}
}
None => unreachable!(),
};
let mut names: Vec<String> = container
.names
.as_ref()
.map(|names| {
names
.iter()
.map(|name| name.trim_start_matches('/').to_owned())
.collect()
})
.unwrap_or_default();
match result.get_mut(&reference) {
Some(containers) => containers.append(&mut names),
None => {
let _ = result.insert(reference, names);
}
}
});
result.clone()
}
/// Given a container name and the update information returned about the image it uses, tries to recreate it with a new image / latest version of the current image
pub async fn upgrade_container(ctx: &Context, name: &str, update: &Update) -> Result<(), String> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref()); // TODO: Consider adding all these functions to a long lived struct with a shared client. We don't want to create a new client for every container updated.
// Create a few variables that will be used later on
let new_name = format!("{name}__cup_temp"); // A new temporary name for the container. Instead of removing the old one straight away, we'll create a new one and if that succeeds we'll rename it.
let new_image = match &update.result.info {
// Find the new reference for the image, based on logic used in the web interface. This will be used to pull the new image
UpdateInfo::Version(update_info) => format!(
"{}:{}",
update
.reference
.split_once(':')
.expect("Reference contains `:`")
.0,
update_info.new_tag
),
UpdateInfo::Digest(_) => update.reference.clone(),
UpdateInfo::None => unreachable!("Tried to update up-to-date image"),
};
ctx.logger.debug(format!("Upgrading {name}..."));
// Retrieve information about current container and construct required structs to create a new container afterwards
let (create_options, create_config) = match client
.inspect_container(name, None::<InspectContainerOptions>)
.await
{
Ok(inspect) => {
let create_options = {
let mut options = CreateContainerOptionsBuilder::new();
match inspect.name {
Some(_) => options = options.name(&new_name),
None => (), // Not sure if this is even reachable
};
match inspect.platform {
Some(platform) => options = options.platform(&platform),
None => (), // Same as above
};
options.build()
};
let inspect_config = inspect.config.unwrap(); // For easier access later
let create_config = ContainerCreateBody {
hostname: inspect_config.hostname,
domainname: inspect_config.domainname,
user: inspect_config.user,
attach_stdin: inspect_config.attach_stdin,
attach_stderr: inspect_config.attach_stderr,
attach_stdout: inspect_config.attach_stdout,
exposed_ports: inspect_config.exposed_ports,
tty: inspect_config.tty,
open_stdin: inspect_config.open_stdin,
stdin_once: inspect_config.stdin_once,
env: inspect_config.env,
cmd: inspect_config.cmd,
healthcheck: inspect_config.healthcheck,
args_escaped: inspect_config.args_escaped,
image: Some(new_image.clone()),
volumes: inspect_config.volumes,
working_dir: inspect_config.working_dir,
entrypoint: inspect_config.entrypoint,
network_disabled: inspect_config.network_disabled,
mac_address: inspect_config.mac_address,
on_build: inspect_config.on_build,
labels: inspect_config.labels,
stop_signal: inspect_config.stop_signal,
stop_timeout: inspect_config.stop_timeout,
shell: inspect_config.shell,
host_config: inspect.host_config,
// The commented out code below doesn't work because bollard sends gw_priority as a float and Docker expects an int. Tracking issue: https://github.com/fussybeaver/bollard/issues/537
// networking_config: Some(bollard::secret::NetworkingConfig {
// endpoints_config: inspect.network_settings.unwrap().networks,
// }),
networking_config: None,
};
(create_options, create_config)
}
Err(e) => {
let message = format!("Failed to inspect container {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
};
// Stop the current container
ctx.logger.debug(format!("Stopping {name}..."));
match client
.stop_container(name, None::<StopContainerOptions>)
.await
{
Ok(()) => ctx.logger.debug(format!("Successfully stopped {name}")),
Err(e) => {
let message = format!("Failed to stop container {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
};
// Don't let the naming fool you, we're pulling the new image here.
ctx.logger.debug(format!("Pulling {new_image} for {name}..."));
let create_image_options = CreateImageOptionsBuilder::new()
.from_image(&new_image)
.build();
client
.create_image(Some(create_image_options), None, None) // TODO: credentials support
.collect::<Vec<Result<CreateImageInfo, bollard::errors::Error>>>() // Not entirely sure this is the best way to handle a stream
.await; // TODO: handle errors here
ctx.logger.debug(format!("Successfully pulled new image for {name}"));
// Create the new container
ctx.logger.debug(format!("Creating new container for {name}..."));
match client
.create_container(Some(create_options), create_config)
.await
{
Ok(response) => {
// Let the user know if any warnings occured
response
.warnings
.iter()
.for_each(|warning| ctx.logger.warn(format!("[DAEMON]: {}", warning)));
},
Err(e) => {
let message = format!("Failed to create new container for {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
};
// Start the new container
match client
.start_container(&new_name, None::<StartContainerOptions>)
.await
{
Ok(()) => ctx.logger.debug(format!("Successfully created new container for {name}")),
Err(e) => {
let message = format!("Failed to start new container for {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
}
// Remove the old container
ctx.logger.debug(format!("Removing old {name} container"));
match client
.remove_container(name, None::<RemoveContainerOptions>)
.await
{
Ok(()) => ctx.logger.debug(format!("Successfully removed old {name} container")),
Err(e) => {
match e {
bollard::errors::Error::DockerResponseServerError { status_code: 404, message } => {
ctx.logger.warn(format!("Failed to remove container {name}, it was probably started with `--rm` and has been automatically cleaned up. Message from server: {message}"))
},
_ => {
let message = format!("Failed to remove container {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
}
}
}
// Rename the new container
match client
.rename_container(
&new_name,
RenameContainerOptions {
name: name.to_owned(),
},
)
.await
{
Ok(()) => (),
Err(e) => {
let message = format!("Failed to rename container {name}: {e}");
ctx.logger.warn(&message);
return Err(message)
},
}
ctx.logger.debug(format!("Successfully upgraded {name}!"));
Ok(())
}

View File

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

View File

@@ -42,7 +42,7 @@ impl Client {
&self,
url: &str,
method: RequestMethod,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
let mut request = match method {
@@ -51,7 +51,7 @@ impl Client {
};
for (name, value) in headers {
if let Some(v) = value {
request = request.header(name, v)
request = request.header(*name, *v)
}
}
match request.send().await {
@@ -69,6 +69,10 @@ impl Client {
self.ctx.logger.warn(&message);
Err(message)
}
} else if status == 502 {
let message = format!("{} {}: The registry is currently unavailabile (returned status code 502).", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if status.as_u16() <= 400 {
Ok(response)
} else {
@@ -95,6 +99,10 @@ impl Client {
let message = format!("{} {}: Connection timed out!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if error.is_middleware() {
let message = format!("{} {}: Connection failed after 3 retries!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else {
error!(
"{} {}: Unexpected error: {}",
@@ -110,7 +118,7 @@ impl Client {
pub async fn get(
&self,
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
self.request(url, RequestMethod::GET, headers, ignore_401)
@@ -120,7 +128,7 @@ impl Client {
pub async fn head(
&self,
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
) -> Result<Response, String> {
self.request(url, RequestMethod::HEAD, headers, false).await
}

View File

@@ -1,14 +1,12 @@
use std::time::SystemTime;
use std::{cmp::Ordering, time::SystemTime};
use itertools::Itertools;
use crate::{
config::UpdateType,
error,
http::Client,
structs::{
image::{DigestInfo, Image, VersionInfo},
version::Version,
},
structs::{image::Image, version::Version},
utils::{
link::parse_link,
request::{
@@ -22,7 +20,7 @@ use crate::{
pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Option<String> {
let protocol = get_protocol(registry, &ctx.config.registries);
let url = format!("{}://{}/v2/", protocol, registry);
let response = client.get(&url, Vec::new(), true).await;
let response = client.get(&url, &[], true).await;
match response {
Ok(response) => {
let status = response.status();
@@ -43,11 +41,11 @@ pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Optio
}
pub async fn get_latest_digest(
image: &Image,
image: &mut Image,
token: Option<&str>,
ctx: &Context,
client: &Client,
) -> Image {
) -> () {
ctx.logger
.debug(format!("Checking for digest update to {}", image.reference));
let start = SystemTime::now();
@@ -57,9 +55,9 @@ pub async fn get_latest_digest(
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
);
let authorization = to_bearer_string(&token);
let headers = vec![("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())];
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())];
let response = client.head(&url, headers).await;
let response = client.head(&url, &headers).await;
let time = start.elapsed().unwrap().as_millis() as u32;
ctx.logger.debug(format!(
"Checked for digest update to {} in {}ms",
@@ -68,34 +66,22 @@ pub async fn get_latest_digest(
match response {
Ok(res) => match res.headers().get("docker-content-digest") {
Some(digest) => {
let local_digests = match &image.digest_info {
Some(data) => data.local_digests.clone(),
None => return image.clone(),
};
Image {
digest_info: Some(DigestInfo {
remote_digest: Some(digest.to_str().unwrap().to_string()),
local_digests,
}),
time_ms: image.time_ms + time,
..image.clone()
}
image.update_info.remote_digest = Some(digest.to_str().unwrap().to_owned());
}
None => error!(
"Server returned invalid response! No docker-content-digest!\n{:#?}",
res
),
},
Err(error) => Image {
error: Some(error),
time_ms: image.time_ms + time,
..image.clone()
},
Err(error) => {
image.error = Some(error);
image.time_ms = image.time_ms + elapsed(start)
}
}
}
pub async fn get_token(
images: &Vec<&Image>,
images: &[&Image],
auth_url: &str,
credentials: &Option<String>,
client: &Client,
@@ -105,9 +91,9 @@ pub async fn get_token(
url = format!("{}&scope=repository:{}:pull", url, image.parts.repository);
}
let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds));
let headers = vec![("Authorization", authorization.as_deref())];
let headers = [("Authorization", authorization.as_deref())];
let response = client.get(&url, headers, false).await;
let response = client.get(&url, &headers, false).await;
let response_json = match response {
Ok(response) => parse_json(&get_response_body(response).await),
Err(_) => error!("GET {}: Request failed!", url),
@@ -116,12 +102,12 @@ pub async fn get_token(
}
pub async fn get_latest_tag(
image: &Image,
image: &mut Image,
base: &Version,
token: Option<&str>,
ctx: &Context,
client: &Client,
) -> Image {
) -> () {
ctx.logger
.debug(format!("Checking for tag update to {}", image.reference));
let start = now();
@@ -131,7 +117,7 @@ pub async fn get_latest_tag(
protocol, &image.parts.registry, &image.parts.repository,
);
let authorization = to_bearer_string(&token);
let headers = vec![
let headers = [
("Accept", Some("application/json")),
("Authorization", authorization.as_deref()),
];
@@ -147,26 +133,32 @@ pub async fn get_latest_tag(
));
let (new_tags, next) = match get_extra_tags(
&next_url.unwrap(),
headers.clone(),
&headers,
base,
&image.version_info.as_ref().unwrap().format_str,
&image.reference,
ctx,
client,
)
.await
{
Ok(t) => t,
Err(message) => {
return Image {
error: Some(message),
time_ms: image.time_ms + elapsed(start),
..image.clone()
}
image.error = Some(message);
image.time_ms += elapsed(start);
return;
}
};
tags.extend_from_slice(&new_tags);
next_url = next;
}
let tag = tags.iter().max();
let tag = tags.iter().reduce(|a, b| match a.partial_cmp(b) {
Some(ordering) => match ordering {
Ordering::Greater => a,
Ordering::Equal => b,
Ordering::Less => b,
},
None => unreachable!(),
});
ctx.logger.debug(format!(
"Checked for tag update to {} in {}ms",
image.reference,
@@ -174,50 +166,36 @@ pub async fn get_latest_tag(
));
match tag {
Some(t) => {
if t == base && image.digest_info.is_some() {
if t == base && !image.info.local_digests.is_empty() {
// Tags are equal so we'll compare digests
ctx.logger.debug(format!(
"Tags for {} are equal, comparing digests.",
image.reference
));
get_latest_digest(
&Image {
version_info: Some(VersionInfo {
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
time_ms: image.time_ms + elapsed(start),
..image.clone()
},
token,
ctx,
client,
)
.await
image.time_ms += elapsed(start);
get_latest_digest(image, token, ctx, client).await
} else {
Image {
version_info: Some(VersionInfo {
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
time_ms: image.time_ms + elapsed(start),
..image.clone()
}
image.update_info.latest_version = Some(t.clone());
image.time_ms += elapsed(start);
}
}
None => unreachable!("{:?}", tags),
None => error!(
"Image {} has no remote version tags! Local tag: {}",
image.reference, image.parts.tag
),
}
}
pub async fn get_extra_tags(
url: &str,
headers: Vec<(&str, Option<&str>)>,
headers: &[(&str, Option<&str>)],
base: &Version,
format_str: &str,
reference: &str,
ctx: &Context,
client: &Client,
) -> Result<(Vec<Version>, Option<String>), String> {
let response = client.get(url, headers, false).await;
let base_type = base.r#type();
match response {
Ok(res) => {
let next_url = res
@@ -229,15 +207,39 @@ pub async fn get_extra_tags(
.as_array()
.unwrap()
.iter()
.filter_map(|tag| Version::from_tag(tag.as_str().unwrap()))
.filter(|(tag, format_string)| match (base.minor, tag.minor) {
(Some(_), Some(_)) | (None, None) => {
matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None))
&& format_str == *format_string
.map(|tag| Version::from(tag.as_str().unwrap(), base_type.as_ref()))
.filter(|tag| tag.r#type() == base_type)
.filter(|tag| tag.partial_cmp(base).is_some())
.filter_map(|tag| {
match ctx
.config
.images
.iter()
.filter(|&(i, _)| reference.starts_with(i))
.sorted_by(|(a, _), (b, _)| a.len().cmp(&b.len()))
.next()
.map(|(_, cfg)| &cfg.ignore)
.unwrap_or(&UpdateType::None)
{
// TODO: Please don't ship it like this
UpdateType::None => Some(tag),
UpdateType::Major => Some(tag).filter(|tag| {
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
}),
UpdateType::Minor => Some(tag).filter(|tag| {
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
&& base.as_standard().unwrap().minor
== tag.as_standard().unwrap().minor
}),
UpdateType::Patch => Some(tag).filter(|tag| {
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
&& base.as_standard().unwrap().minor
== tag.as_standard().unwrap().minor
&& base.as_standard().unwrap().patch
== tag.as_standard().unwrap().patch
}),
}
_ => false,
})
.map(|(tag, _)| tag)
.dedup()
.collect();
Ok((result, next_url))

View File

@@ -1,17 +1,20 @@
use std::sync::Arc;
use std::{env, sync::Arc, time::SystemTime};
use chrono::Local;
use chrono_tz::Tz;
use liquid::{object, Object, ValueView};
use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use tokio_cron_scheduler::{Job, JobScheduler};
use xitca_web::{
body::ResponseBody,
bytes::Bytes,
error::Error,
handler::{handler_service, path::PathRef, state::StateRef},
handler::{handler_service, json::LazyJson, path::PathRef, state::StateRef},
http::{StatusCode, WebResponse},
route::get,
route::{get, post},
service::Service,
App, WebContext,
};
@@ -19,9 +22,11 @@ use xitca_web::{
use crate::{
check::get_updates,
config::Theme,
docker::upgrade_container,
error,
structs::update::Update,
utils::{
json::{to_full_json, to_simple_json},
json::to_json,
sort_update_vec::sort_update_vec,
time::{elapsed, now},
},
@@ -31,9 +36,13 @@ use crate::{
const HTML: &str = include_str!("static/index.html");
const JS: &str = include_str!("static/assets/index.js");
const CSS: &str = include_str!("static/assets/index.css");
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
const FAVICON_ICO: Bytes = Bytes::from_static(include_bytes!("static/favicon.ico"));
const FAVICON_SVG: Bytes = Bytes::from_static(include_bytes!("static/favicon.svg"));
const APPLE_TOUCH_ICON: Bytes = Bytes::from_static(include_bytes!("static/apple-touch-icon.png"));
const SUCCESS_STATUS: &str = r#"{"success":true}"#; // Store this to avoid recomputation
const UPGRADE_INTERNAL_SERVER_ERROR: &str =
r#"{"success":"false","message":"Internal server error. Please view logs for details"}"#;
const SORT_ORDER: [&str; 8] = [
"monitored_images",
@@ -50,18 +59,36 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
ctx.logger.info("Starting server, please wait...");
let data = ServerData::new(ctx).await;
let scheduler = JobScheduler::new().await.unwrap();
let data = Arc::new(Mutex::new(data));
let data = Arc::new(RwLock::new(data));
let data_copy = data.clone();
let tz = env::var("TZ")
.map(|tz| tz.parse().unwrap_or(Tz::UTC))
.unwrap_or(Tz::UTC);
if let Some(interval) = &ctx.config.refresh_interval {
scheduler
.add(
Job::new_async(interval, move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.lock().await.refresh().await;
})
})
.unwrap(),
match Job::new_async_tz(
interval,
tz,
move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.write().await.refresh().await;
})
},
) {
Ok(job) => job,
Err(e) => match e {
tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!(
"Failed to parse cron schedule: {}. Please ensure it is valid!",
interval
),
e => error!(
"An unexpected error occured while scheduling automatic refresh: {}",
e
),
},
},
)
.await
.unwrap();
@@ -70,34 +97,39 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
ctx.logger.info("Ready to start!");
let mut app_builder = App::new()
.with_state(data)
.at("/api/v2/json", get(handler_service(api_simple)))
.at("/api/v3/json", get(handler_service(api_full)))
.at("/api/v2/refresh", get(handler_service(refresh)))
.at("/api/v3/refresh", get(handler_service(refresh)));
.at("/api/v3/json", get(handler_service(json)))
.at("/api/v3/refresh", get(handler_service(refresh_v3)))
.at("/api/v4/json", get(handler_service(json)))
.at("/api/v4/refresh", get(handler_service(refresh_v4)))
.at("/api/v4/upgrade", post(handler_service(upgrade)));
if !ctx.config.agent {
app_builder = app_builder
.at("/", get(handler_service(_static)))
.at("/*", get(handler_service(_static)));
}
app_builder
match app_builder
.enclosed_fn(logger)
.serve()
.bind(format!("0.0.0.0:{}", port))?
.run()
.wait()
.bind(format!("0.0.0.0:{}", port))
{
Ok(r) => r,
Err(_) => error!("Failed to bind to port {}. Is it in use?", port),
}
.run()
.wait()
}
async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>) -> WebResponse {
async fn _static(data: StateRef<'_, Arc<RwLock<ServerData>>>, path: PathRef<'_>) -> WebResponse {
match path.0 {
"/" => WebResponse::builder()
.header("Content-Type", "text/html")
.body(ResponseBody::from(data.lock().await.template.clone()))
.body(ResponseBody::from(data.read().await.template.clone()))
.unwrap(),
"/assets/index.js" => WebResponse::builder()
.header("Content-Type", "text/javascript")
.body(ResponseBody::from(JS.replace(
"=\"neutral\"",
&format!("=\"{}\"", data.lock().await.theme),
&format!("=\"{}\"", data.read().await.theme),
)))
.unwrap(),
"/assets/index.css" => WebResponse::builder()
@@ -123,34 +155,60 @@ async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>)
}
}
async fn api_simple(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
async fn json(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
WebResponse::builder()
.header("Content-Type", "application/json")
.body(ResponseBody::from(
data.lock().await.simple_json.clone().to_string(),
data.read().await.json.clone().to_string(),
))
.unwrap()
}
async fn api_full(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::builder()
.header("Content-Type", "application/json")
.body(ResponseBody::from(
data.lock().await.full_json.clone().to_string(),
))
.unwrap()
}
async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
data.lock().await.refresh().await;
async fn refresh_v3(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
data.write().await.refresh().await;
WebResponse::new(ResponseBody::from("OK"))
}
async fn refresh_v4(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
data.write().await.refresh().await;
WebResponse::new(ResponseBody::from(SUCCESS_STATUS))
}
#[derive(Deserialize)]
struct UpgradeRequest {
name: String, // Container name to be upgraded
}
async fn upgrade(
data: StateRef<'_, Arc<RwLock<ServerData>>>,
body: LazyJson<UpgradeRequest>,
) -> WebResponse {
let data = data.read().await;
let UpgradeRequest { name } = match body.deserialize::<UpgradeRequest>() {
Ok(ur) => ur,
Err(e) => {
return WebResponse::builder().status(StatusCode::BAD_REQUEST).body(ResponseBody::from(serde_json::json!({"success": "false", "message": format!("Invalid JSON payload: {e}")}).to_string())).unwrap()
}
};
match data.raw_updates.iter().find(|update| {
update.used_by.contains(&name)
&& update.status.to_option_bool().is_some_and(|status| status)
}) {
Some(update) => match upgrade_container(&data.ctx, &name, update).await {
Ok(()) => WebResponse::new(ResponseBody::from(SUCCESS_STATUS)),
Err(_) => WebResponse::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(ResponseBody::from(UPGRADE_INTERNAL_SERVER_ERROR))
.unwrap(),
},
None => WebResponse::builder().status(StatusCode::BAD_REQUEST).body(ResponseBody::from(serde_json::json!({"success": "false", "message": format!("Container `{name}` does not exist or has no updates")}).to_string())).unwrap(),
}
}
struct ServerData {
template: String,
raw_updates: Vec<Update>,
simple_json: Value,
full_json: Value,
json: Value,
ctx: Context,
theme: &'static str,
}
@@ -160,10 +218,12 @@ impl ServerData {
let mut s = Self {
ctx: ctx.clone(),
template: String::new(),
simple_json: Value::Null,
full_json: Value::Null,
json: Value::Null,
raw_updates: Vec::new(),
theme: "neutral",
theme: match ctx.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray",
},
};
s.refresh().await;
s
@@ -185,19 +245,13 @@ impl ServerData {
.unwrap()
.parse(HTML)
.unwrap();
self.simple_json = to_simple_json(&self.raw_updates);
self.full_json = to_full_json(&self.raw_updates);
self.json = to_json(&self.raw_updates);
let last_updated = Local::now();
self.simple_json["last_updated"] = last_updated
self.json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.full_json["last_updated"] = self.simple_json["last_updated"].clone();
self.theme = match &self.ctx.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray",
};
let mut metrics = self.simple_json["metrics"]
let mut metrics = self.json["metrics"]
.as_object()
.unwrap()
.iter()
@@ -247,17 +301,11 @@ where
let method = request.method().to_string();
let url = request.uri().to_string();
if &method != "GET" {
// We only allow GET requests
log(&method, &url, 405, elapsed(start));
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED))
} else {
let res = next.call(ctx).await?;
let status = res.status().as_u16();
log(&method, &url, status, elapsed(start));
Ok(res)
match (method.as_str(), url.as_str()) {
("POST", "/api/v4/upgrade") => continue_request(ctx, next, &method, &url, start).await,
("GET", "/api/v4/upgrade") | ("POST", _) => return_405(&method, &url, start).await,
("GET", _) => continue_request(ctx, next, &method, &url, start).await,
(_, _) => return_405(&method, &url, start).await,
}
}
@@ -274,3 +322,29 @@ fn log(method: &str, url: &str, status: u16, time: u32) {
method, url, color, status, time
)
}
async fn continue_request<S, C, B>(
ctx: WebContext<'_, C, B>,
next: &S,
method: &str,
url: &str,
start: SystemTime,
) -> Result<WebResponse, Error<C>>
where
S: for<'r> Service<WebContext<'r, C, B>, Response = WebResponse, Error = Error<C>>,
{
let res = next.call(ctx).await?;
let status = res.status().as_u16();
log(&method, &url, status, elapsed(start));
Ok(res)
}
async fn return_405<C>(
method: &str,
url: &str,
start: SystemTime,
) -> Result<WebResponse, Error<C>> {
log(&method, &url, 405, elapsed(start));
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED))
}

View File

@@ -2,7 +2,7 @@ use crate::{
error,
http::Client,
registry::{get_latest_digest, get_latest_tag},
structs::{status::Status, version::Version},
structs::{standard_version::StandardVersionPart, status::Status, update::Update, version::Version},
utils::reference::split,
Context,
};
@@ -10,49 +10,65 @@ use crate::{
use super::{
inspectdata::InspectData,
parts::Parts,
update::{DigestUpdateInfo, Update, UpdateInfo, UpdateResult, VersionUpdateInfo},
update::{DigestUpdateInfo, UpdateResult, VersionUpdateInfo},
};
#[derive(Clone, PartialEq)]
/// Any local information about the image
#[derive(Clone, Default)]
#[cfg_attr(test, derive(Debug))]
pub struct DigestInfo {
pub struct Info {
pub local_digests: Vec<String>,
pub remote_digest: Option<String>,
pub version: Version,
pub url: Option<String>,
pub used_by: Vec<String>,
}
#[derive(Clone, PartialEq)]
#[cfg_attr(test, derive(Debug))]
pub struct VersionInfo {
pub current_tag: Version,
pub latest_remote_tag: Option<Version>,
pub format_str: String,
/// Any new information obtained about the image
#[derive(Debug, Clone, Default)]
pub struct UpdateInfo {
pub remote_digest: Option<String>,
pub latest_version: Option<Version>
}
/// Image struct that contains all information that may be needed by a function working with an image.
/// It's designed to be passed around between functions
#[derive(Clone, PartialEq, Default)]
#[derive(Clone, Default)]
#[cfg_attr(test, derive(Debug))]
pub struct Image {
pub reference: String,
pub parts: Parts,
pub digest_info: Option<DigestInfo>,
pub version_info: Option<VersionInfo>,
pub info: Info,
pub update_info: UpdateInfo,
pub error: Option<String>,
pub time_ms: u32,
}
impl Image {
/// Creates and populates the fields of an Image object based on the ImageSummary from the Docker daemon
pub fn from_inspect_data<T: InspectData>(image: T) -> Option<Self> {
pub fn from_inspect_data<T: InspectData>(ctx: &Context, image: T) -> Option<Self> {
let tags = image.tags().unwrap();
let digests = image.digests().unwrap();
if !tags.is_empty() && !digests.is_empty() {
let reference = tags[0].clone();
if reference.contains('@') {
return None; // As far as I know, references that contain @ are either manually pulled by the user or automatically created because of swarm. In the first case AFAICT we can't know what tag was originally pulled, so we'd have to make assumptions and I've decided to remove this. The other case is already handled seperately, so this also ensures images aren't displayed twice, once with and once without a digest.
};
let (registry, repository, tag) = split(&reference);
let version_tag = Version::from_tag(&tag);
let version_tag = Version::from(&tag, ctx.config.images.get(&reference).map(|cfg| &cfg.tag_type));
let local_digests = digests
.iter()
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
.filter_map(
|digest| match digest.split('@').collect::<Vec<&str>>().get(1) {
Some(digest) => Some(digest.to_string()),
None => {
ctx.logger.warn(format!(
"Ignoring invalid digest {} for image {}!",
digest, reference
));
None
}
},
)
.collect();
Some(Self {
reference,
@@ -61,15 +77,7 @@ impl Image {
repository,
tag,
},
digest_info: Some(DigestInfo {
local_digests,
remote_digest: None,
}),
version_info: version_tag.map(|(vtag, format_str)| VersionInfo {
current_tag: vtag,
format_str,
latest_remote_tag: None,
}),
info: Info { local_digests, version: version_tag, url: image.url(), used_by: Vec::new() },
..Default::default()
})
} else {
@@ -78,28 +86,24 @@ impl Image {
}
/// Creates and populates the fields of an Image object based on a reference. If the tag is not recognized as a version string, exits the program with an error.
pub fn from_reference(reference: &str) -> Self {
pub fn from_reference(reference: &str, ctx: &Context) -> Self {
let (registry, repository, tag) = split(reference);
let version_tag = Version::from_tag(&tag);
let version_tag = Version::from(&tag, ctx.config.images.get(reference).map(|cfg| &cfg.tag_type));
match version_tag {
Some((version, format_str)) => Self {
Version::Unknown => error!(
"Image {} is not available locally and does not have a recognizable tag format!",
reference
),
v => Self {
reference: reference.to_string(),
parts: Parts {
registry,
repository,
tag,
},
version_info: Some(VersionInfo {
current_tag: version,
format_str,
latest_remote_tag: None,
}),
info: Info { local_digests: Vec::new(), version: v, url: None, used_by: Vec::new() },
..Default::default()
},
None => error!(
"Image {} is not available locally and does not have a recognizable tag format!",
reference
),
}
}
@@ -107,25 +111,18 @@ impl Image {
if self.error.is_some() {
Status::Unknown(self.error.clone().unwrap())
} else {
match &self.version_info {
Some(data) => data
.latest_remote_tag
.as_ref()
.unwrap()
.to_status(&data.current_tag),
None => match &self.digest_info {
Some(data) => {
if data
.local_digests
.contains(data.remote_digest.as_ref().unwrap())
{
match self.update_info.latest_version {
Some(latest_version) => latest_version.to_status(self.info.version),
None => match self.update_info.remote_digest {
Some(remote_digest) => {
if self.info.local_digests.contains(&remote_digest) {
Status::UpToDate
} else {
Status::UpdateAvailable
}
}
None => unreachable!(), // I hope?
},
},
None => unreachable!() // I hope?
}
}
}
}
@@ -141,19 +138,14 @@ impl Image {
Update {
reference: self.reference.clone(),
parts: self.parts.clone(),
url: self.info.url.clone(),
result: UpdateResult {
has_update: has_update.to_option_bool(),
info: match has_update {
Status::Unknown(_) => UpdateInfo::None,
Status::Unknown(_) => crate::structs::update::UpdateInfo::None,
_ => match update_type {
"version" => {
let (new_tag, format_str) = match &self.version_info {
Some(data) => (
data.latest_remote_tag.clone().unwrap(),
data.format_str.clone(),
),
_ => unreachable!(),
};
let update_info = &self.update_info.latest_version.unwrap().as_standard().unwrap();
UpdateInfo::Version(VersionUpdateInfo {
version_update_type: match has_update {
@@ -163,12 +155,12 @@ impl Image {
_ => unreachable!(),
}
.to_string(),
new_tag: format_str
.replacen("{}", &new_tag.major.to_string(), 1)
.replacen("{}", &new_tag.minor.unwrap_or(0).to_string(), 1)
.replacen("{}", &new_tag.patch.unwrap_or(0).to_string(), 1),
new_tag: update_info.format_str
.replacen("{}", &update_info.major.to_string(), 1)
.replacen("{}", &update_info.minor.unwrap_or_default().to_string(), 1)
.replacen("{}", &update_info.patch.unwrap_or_default().to_string(), 1),
// Throwing these in, because they're useful for the CLI output, however we won't (de)serialize them
current_version: self
current_version: self.info.version.as_standard().unwrap().to_string()
.version_info
.as_ref()
.unwrap()
@@ -204,6 +196,7 @@ impl Image {
},
time: self.time_ms,
server: None,
used_by: self.used_by.clone(),
status: has_update,
}
}

View File

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

View File

@@ -4,3 +4,4 @@ pub mod parts;
pub mod status;
pub mod update;
pub mod version;
pub mod standard_version;

View File

@@ -0,0 +1,180 @@
use std::{cmp::Ordering, fmt::Display};
use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Clone, PartialEq, Debug, Default)] // Default is so I can avoid constructing a struct every time I want to use a version number of 0 as a default.
pub struct StandardVersionPart {
value: u32,
length: u8, // If the value is prefixed by zeroes, the total length, otherwise 0
}
impl StandardVersionPart {
fn from_split(split: &str) -> Self {
if split.len() == 1 && split == "0" {
Self::default()
} else {
Self {
value: split.parse().expect("Expected number to be less than 2^32"), // Unwrapping is safe, because we've verified that the string consists of digits and we don't care about supporting big numbers.
length: {
if split.starts_with('0') {
split.len() as u8 // We're casting the zeroes to u8, because no sane person uses more than 255 zeroes as a version prefix. Oh wait, tags can't even be that long
} else {
0
}
},
}
}
}
}
impl PartialOrd for StandardVersionPart {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.length == other.length {
self.value.partial_cmp(&other.value)
} else {
None
}
}
}
impl Display for StandardVersionPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{:0<zeroes$}",
self.value,
zeroes = self.length as usize
))
}
}
/// Represents a semver-like version.
/// While not conforming to the SemVer standard, but was designed to handle common versioning schemes across a wide range of Docker images.
/// Minor and patch versions are considered optional.
/// Matching happens with a regex.
#[derive(Clone, PartialEq, Debug)]
pub struct StandardVersion {
pub major: StandardVersionPart,
pub minor: Option<StandardVersionPart>,
pub patch: Option<StandardVersionPart>,
pub format_str: String, // The tag with {} in the place the version was matched.
}
impl StandardVersion {
/// Tries to extract a semver-like version from a tag.
/// Returns a Result<StandardVersion, ()> indicating whether parsing succeeded
pub fn from_tag(tag: &str) -> Result<Self, ()> {
/// 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.
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?P<major>[0-9]+)(?:\.(?P<minor>[0-9]*))?(?:\.(?P<patch>[0-9]*))?")
.unwrap()
});
let mut captures = VERSION_REGEX.captures_iter(tag);
// And now... terrible best match selection for everyone! Actually, it's probably not that terrible. I don't know.
match captures.next() {
Some(mut best_match) => {
let mut max_matches: u8 = 0; // Why does Rust not have `u2`s?
for capture in captures {
let count = capture.iter().filter_map(|c| c).count() as u8;
if count > max_matches {
max_matches = count;
best_match = capture;
}
}
let start_pos;
let mut end_pos;
let major: StandardVersionPart = match best_match.name("major") {
Some(major) => {
start_pos = major.start();
end_pos = major.end();
StandardVersionPart::from_split(major.as_str())
}
None => return Err(()),
};
let minor: Option<StandardVersionPart> = best_match.name("minor").map(|minor| {
end_pos = minor.end();
StandardVersionPart::from_split(minor.as_str())
});
let patch: Option<StandardVersionPart> = best_match.name("patch").map(|patch| {
end_pos = patch.end();
StandardVersionPart::from_split(patch.as_str())
});
let mut format_str = tag.to_string();
format_str.replace_range(start_pos..end_pos, "{}");
Ok(Self {
major,
minor,
patch,
format_str,
})
}
None => Err(()),
}
}
}
impl PartialOrd for StandardVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.format_str != other.format_str {
None
} else {
match self.major.partial_cmp(&other.major) {
Some(ordering) => match ordering {
Ordering::Equal => match self.minor.partial_cmp(&other.minor) {
Some(ordering) => match ordering {
Ordering::Equal => self.patch.partial_cmp(&other.patch),
_ => Some(ordering),
},
None => None,
},
_ => Some(ordering),
},
None => None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[rustfmt::skip]
fn standard_version() {
assert_eq!(StandardVersion::from_tag("5.3.2"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("14"), Ok(StandardVersion { major: StandardVersionPart { value: 14, length: 0 }, minor: None, patch: None , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("v0.107.53"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 107, length: 0 }), patch: Some(StandardVersionPart { value: 53, length: 0 }) , format_str: String::from("v{}") }));
assert_eq!(StandardVersion::from_tag("12-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 12, length: 0 }, minor: None, patch: None , format_str: String::from("{}-alpine") }));
assert_eq!(StandardVersion::from_tag("0.9.5-nginx"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 9, length: 0 }), patch: Some(StandardVersionPart { value: 5, length: 0 }) , format_str: String::from("{}-nginx") }));
assert_eq!(StandardVersion::from_tag("v27.0"), Ok(StandardVersion { major: StandardVersionPart { value: 27, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: None , format_str: String::from("v{}") }));
assert_eq!(StandardVersion::from_tag("16.1"), Ok(StandardVersion { major: StandardVersionPart { value: 16, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: None , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("version-1.5.6"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 5, length: 0 }), patch: Some(StandardVersionPart { value: 6, length: 0 }) , format_str: String::from("version-{}") }));
assert_eq!(StandardVersion::from_tag("15.4-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 0 }), patch: None , format_str: String::from("{}-alpine") }));
assert_eq!(StandardVersion::from_tag("pg14-v0.2.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 2, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("pg14-v{}") }));
assert_eq!(StandardVersion::from_tag("18-jammy-full.s6-v0.88.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 88, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("18-jammy-full.s6-v{}") }));
assert_eq!(StandardVersion::from_tag("fpm-2.1.0-prod"), Ok(StandardVersion { major: StandardVersionPart { value: 2, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("fpm-{}-prod") }));
assert_eq!(StandardVersion::from_tag("7.3.3.50"), Ok(StandardVersion { major: StandardVersionPart { value: 7, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("{}.50") }));
assert_eq!(StandardVersion::from_tag("1.21.11-0"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 21, length: 0 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}-0") }));
assert_eq!(StandardVersion::from_tag("4.1.2.1-full"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}.1-full") }));
assert_eq!(StandardVersion::from_tag("v4.0.3-ls215"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("v{}-ls215") }));
assert_eq!(StandardVersion::from_tag("24.04.11.2.1"), Ok(StandardVersion { major: StandardVersionPart { value: 24, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 2 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}.2.1") }));
assert_eq!(StandardVersion::from_tag("example15-test"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: None, patch: None , format_str: String::from("example{}-test") }));
assert_eq!(StandardVersion::from_tag("watch-the-dot-5.3.2.careful"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }), format_str: String::from("watch-the-dot-{}.careful") }));
}
#[test]
fn version_part() {
assert_eq!(
format!(
"{:?}",
StandardVersionPart {
value: 21,
length: 4
}
),
String::from("0021")
);
}
}

View File

@@ -1,8 +1,7 @@
use std::fmt::Display;
/// Enum for image status
#[derive(Ord, Eq, PartialEq, PartialOrd, Clone)]
#[cfg_attr(test, derive(Debug))]
#[derive(Ord, Eq, PartialEq, PartialOrd, Clone, Debug)]
pub enum Status {
UpdateMajor,
UpdateMinor,

View File

@@ -2,38 +2,40 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
use super::{parts::Parts, status::Status};
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(test, derive(PartialEq, Debug, Default))]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[cfg_attr(test, derive(PartialEq, Default))]
pub struct Update {
pub reference: String,
pub parts: Parts,
pub url: Option<String>,
pub result: UpdateResult,
pub time: u32,
pub server: Option<String>,
pub used_by: Vec<String>,
#[serde(skip_serializing, skip_deserializing)]
pub status: Status,
}
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(test, derive(PartialEq, Debug, Default))]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[cfg_attr(test, derive(PartialEq, Default))]
pub struct UpdateResult {
pub has_update: Option<bool>,
pub info: UpdateInfo,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(test, derive(PartialEq, Debug, Default))]
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
#[serde(untagged)]
pub enum UpdateInfo {
#[cfg_attr(test, default)]
#[default]
None,
Version(VersionUpdateInfo),
Digest(DigestUpdateInfo),
Digest(String), // Remote digest
}
#[derive(Deserialize, Clone)]
#[cfg_attr(test, derive(PartialEq, Debug))]
#[derive(Deserialize, Clone, Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct VersionUpdateInfo {
pub version_update_type: String,
pub new_tag: String,
@@ -41,8 +43,8 @@ pub struct VersionUpdateInfo {
pub new_version: String,
}
#[derive(Deserialize, Clone)]
#[cfg_attr(test, derive(PartialEq, Debug))]
#[derive(Deserialize, Clone, Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct DigestUpdateInfo {
pub local_digests: Vec<String>,
pub remote_digest: Option<String>,
@@ -53,10 +55,12 @@ impl Serialize for VersionUpdateInfo {
where
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("VersionUpdateInfo", 3)?;
let mut state = serializer.serialize_struct("VersionUpdateInfo", 5)?;
let _ = state.serialize_field("type", "version");
let _ = state.serialize_field("version_update_type", &self.version_update_type);
let _ = state.serialize_field("new_tag", &self.new_tag);
let _ = state.serialize_field("current_version", &self.current_version);
let _ = state.serialize_field("new_version", &self.new_version);
state.end()
}
}
@@ -100,4 +104,4 @@ impl Update {
status => status.clone(),
}
}
}
}

View File

@@ -1,182 +1,63 @@
use std::{cmp::Ordering, fmt::Display};
use crate::{config::TagType, structs::standard_version::StandardVersion};
use once_cell::sync::Lazy;
use regex::Regex;
use super::status::Status;
/// Semver-like version struct
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Version {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
#[derive(Clone, Default, PartialEq, Debug)]
#[non_exhaustive]
pub enum Version {
#[default]
Unknown,
Semver(StandardVersion),
}
impl Version {
/// 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
pub fn from_tag(tag: &str) -> Option<(Self, String)> {
/// 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.
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?P<major>0|[1-9][0-9]*)(?:\.(?P<minor>0|[1-9][0-9]*))?(?:\.(?P<patch>0|[1-9][0-9]*))?",
)
.unwrap()
});
let captures = VERSION_REGEX.captures_iter(tag);
// And now... terrible best match selection for everyone!
let mut max_matches = 0;
let mut best_match = None;
for capture in captures {
let mut count = 0;
for idx in 1..capture.len() {
if capture.get(idx).is_some() {
count += 1
} else {
break;
}
}
if count > max_matches {
max_matches = count;
best_match = Some(capture);
}
}
match best_match {
Some(c) => {
let mut positions = Vec::new();
let major: u32 = match c.name("major") {
Some(major) => {
positions.push((major.start(), major.end()));
match major.as_str().parse() {
Ok(m) => m,
Err(_) => return None
}
}
None => return None,
};
let minor: Option<u32> = c.name("minor").map(|minor| {
positions.push((minor.start(), minor.end()));
minor.as_str().parse().unwrap_or_else(|_| panic!("Minor version invalid in tag {}", tag))
});
let patch: Option<u32> = c.name("patch").map(|patch| {
positions.push((patch.start(), patch.end()));
patch.as_str().parse().unwrap_or_else(|_| panic!("Patch version invalid in tag {}", tag))
});
let mut format_str = tag.to_string();
positions.reverse();
positions.iter().for_each(|(start, end)| {
format_str.replace_range(*start..*end, "{}");
});
Some((
Version {
major,
minor,
patch,
},
format_str,
))
}
None => None,
pub fn from_standard(tag: &str) -> Result<Self, ()> {
match StandardVersion::from_tag(tag) {
Ok(version) => Ok(Version::Semver(version)),
Err(e) => Err(e),
}
}
pub fn to_status(&self, base: &Self) -> Status {
match self.major.cmp(&base.major) {
Ordering::Greater => Status::UpdateMajor,
Ordering::Equal => match (self.minor, base.minor) {
(Some(a_minor), Some(b_minor)) => match a_minor.cmp(&b_minor) {
Ordering::Greater => Status::UpdateMinor,
Ordering::Equal => match (self.patch, base.patch) {
(Some(a_patch), Some(b_patch)) => match a_patch.cmp(&b_patch) {
Ordering::Greater => Status::UpdatePatch,
Ordering::Equal => Status::UpToDate,
Ordering::Less => {
Status::Unknown(format!("Tag {} does not exist", base))
}
},
(None, None) => Status::UpToDate,
_ => unreachable!(),
},
Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)),
},
(None, None) => Status::UpToDate,
_ => unreachable!(
"Version error: {} and {} should either both be Some or None",
self, base
),
},
Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)),
pub fn format_string(&self) -> Option<String> {
match self {
Self::Semver(v) => Some(v.format_str.clone()),
Self::Unknown => None,
}
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
let major_ordering = self.major.cmp(&other.major);
match major_ordering {
Ordering::Equal => match (self.minor, other.minor) {
(Some(self_minor), Some(other_minor)) => {
let minor_ordering = self_minor.cmp(&other_minor);
match minor_ordering {
Ordering::Equal => match (self.patch, other.patch) {
(Some(self_patch), Some(other_patch)) => self_patch.cmp(&other_patch),
_ => Ordering::Equal,
},
_ => minor_ordering,
}
}
_ => Ordering::Equal,
pub fn from(tag: &str, tag_type: Option<&TagType>) -> Self {
match tag_type {
Some(t) => match t {
TagType::Standard => Self::from_standard(tag).unwrap_or(Self::Unknown),
TagType::Extended => unimplemented!(),
},
_ => major_ordering,
None => match Self::from_standard(tag) {
Ok(v) => v,
Err(_) => Self::Unknown, // match self.from_...
},
}
}
pub fn r#type(&self) -> Option<TagType> {
match self {
Self::Semver(_) => Some(TagType::Standard),
Self::Unknown => None
}
}
pub fn as_standard(&self) -> Option<&StandardVersion> {
match self {
Self::Semver(s) => Some(s),
_ => None
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!(
"{}{}{}",
self.major,
match self.minor {
Some(minor) => format!(".{}", minor),
None => String::new(),
},
match self.patch {
Some(patch) => format!(".{}", patch),
None => String::new(),
}
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[rustfmt::skip]
fn version() {
assert_eq!(Version::from_tag("5.3.2" ), Some((Version { major: 5, minor: Some(3), patch: Some(2) }, String::from("{}.{}.{}" ))));
assert_eq!(Version::from_tag("14" ), Some((Version { major: 14, minor: None, patch: None }, String::from("{}" ))));
assert_eq!(Version::from_tag("v0.107.53" ), Some((Version { major: 0, minor: Some(107), patch: Some(53) }, String::from("v{}.{}.{}" ))));
assert_eq!(Version::from_tag("12-alpine" ), Some((Version { major: 12, minor: None, patch: None }, String::from("{}-alpine" ))));
assert_eq!(Version::from_tag("0.9.5-nginx" ), Some((Version { major: 0, minor: Some(9), patch: Some(5) }, String::from("{}.{}.{}-nginx" ))));
assert_eq!(Version::from_tag("v27.0" ), Some((Version { major: 27, minor: Some(0), patch: None }, String::from("v{}.{}" ))));
assert_eq!(Version::from_tag("16.1" ), Some((Version { major: 16, minor: Some(1), patch: None }, String::from("{}.{}" ))));
assert_eq!(Version::from_tag("version-1.5.6" ), Some((Version { major: 1, minor: Some(5), patch: Some(6) }, String::from("version-{}.{}.{}" ))));
assert_eq!(Version::from_tag("15.4-alpine" ), Some((Version { major: 15, minor: Some(4), patch: None }, String::from("{}.{}-alpine" ))));
assert_eq!(Version::from_tag("pg14-v0.2.0" ), Some((Version { major: 0, minor: Some(2), patch: Some(0) }, String::from("pg14-v{}.{}.{}" ))));
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{}.{}.{}"))));
assert_eq!(Version::from_tag("fpm-2.1.0-prod" ), Some((Version { major: 2, minor: Some(1), patch: Some(0) }, String::from("fpm-{}.{}.{}-prod" ))));
assert_eq!(Version::from_tag("7.3.3.50" ), Some((Version { major: 7, minor: Some(3), patch: Some(3) }, String::from("{}.{}.{}.50" ))));
assert_eq!(Version::from_tag("1.21.11-0" ), Some((Version { major: 1, minor: Some(21), patch: Some(11) }, String::from("{}.{}.{}-0" ))));
assert_eq!(Version::from_tag("4.1.2.1-full" ), Some((Version { major: 4, minor: Some(1), patch: Some(2) }, String::from("{}.{}.{}.1-full" ))));
assert_eq!(Version::from_tag("v4.0.3-ls215" ), Some((Version { major: 4, minor: Some(0), patch: Some(3) }, String::from("v{}.{}.{}-ls215" ))));
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
match (self, other) {
(Self::Unknown, Self::Unknown)
| (Self::Unknown, Self::Semver(_))
| (Self::Semver(_), Self::Unknown) => None, // Could also just implement the other arms first and leave this as _, but better be explicit rather than implicit
(Self::Semver(a), Self::Semver(b)) => a.partial_cmp(b),
}
}
}

View File

@@ -1,6 +1,6 @@
// Functions that return JSON data, used for generating output and API responses
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use crate::structs::{status::Status, update::Update};
@@ -47,27 +47,8 @@ pub fn get_metrics(updates: &[Update]) -> Value {
})
}
/// Takes a slice of `Image` objects and returns a `Value` with update info. The output doesn't contain much detail
pub fn to_simple_json(updates: &[Update]) -> Value {
let mut update_map = Map::new();
updates.iter().for_each(|update| {
let _ = update_map.insert(
update.reference.clone(),
match update.result.has_update {
Some(has_update) => Value::Bool(has_update),
None => Value::Null,
},
);
});
let json_data: Value = json!({
"metrics": get_metrics(updates),
"images": updates,
});
json_data
}
/// Takes a slice of `Image` objects and returns a `Value` with update info. All image data is included, useful for debugging.
pub fn to_full_json(updates: &[Update]) -> Value {
pub fn to_json(updates: &[Update]) -> Value {
json!({
"metrics": get_metrics(updates),
"images": updates.iter().map(|update| serde_json::to_value(update).unwrap()).collect::<Vec<Value>>(),

View File

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

View File

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

1
web/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
tsconfig.*.tsbuildinfo
# Editor directories and files
.vscode/*

View File

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

705
web/bun.lock Normal file
View File

@@ -0,0 +1,705 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "web",
"dependencies": {
"@headlessui/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.2",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.42",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.6.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint/config-array": ["@eslint/config-array@0.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.2.1", "", {}, "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw=="],
"@eslint/core": ["@eslint/core@0.13.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.25.1", "", {}, "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.8", "", { "dependencies": { "@eslint/core": "^0.13.0", "levn": "^0.4.1" } }, "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA=="],
"@floating-ui/core": ["@floating-ui/core@1.6.9", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw=="],
"@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="],
"@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@headlessui/react": ["@headlessui/react@2.2.2", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", "@tanstack/react-virtual": "^3.13.6", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-zbniWOYBQ8GHSUIOPY7BbdIn6PzUOq0z41RFrF30HbjsxG6Rrfk+6QulR8Kgf2Vwj2a/rE6i62q5vo+2gI5dJA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.2", "", {}, "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pHVzDYsnaDmBlAuwim45y3soIN8H4R7KbkSVirGhXO+R/kO2OLCe0eucUEbddaTcdMHHdzcIGHtZSMSQlA+apw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.4", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.4", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.0", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.4", "@radix-ui/react-portal": "1.1.6", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DyW8VVeeMSSLFvAmnVnCwvI3H+1tpJFHT50r+tdOoMse9XqYDBCcyux8u3G2y+LOpt7fPQ6KKH0mhs+ce1+Z5w=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.0", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-aria/focus": ["@react-aria/focus@3.20.2", "", { "dependencies": { "@react-aria/interactions": "^3.25.0", "@react-aria/utils": "^3.28.2", "@react-types/shared": "^3.29.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q=="],
"@react-aria/interactions": ["@react-aria/interactions@3.25.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.8", "@react-aria/utils": "^3.28.2", "@react-stately/flags": "^3.1.1", "@react-types/shared": "^3.29.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-GgIsDLlO8rDU/nFn6DfsbP9rfnzhm8QFjZkB9K9+r+MTSCn7bMntiWQgMM+5O6BiA8d7C7x4zuN4bZtc0RBdXQ=="],
"@react-aria/ssr": ["@react-aria/ssr@3.9.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw=="],
"@react-aria/utils": ["@react-aria/utils@3.28.2", "", { "dependencies": { "@react-aria/ssr": "^3.9.8", "@react-stately/flags": "^3.1.1", "@react-stately/utils": "^3.10.6", "@react-types/shared": "^3.29.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-J8CcLbvnQgiBn54eeEvQQbIOfBF3A1QizxMw9P4cl9MkeR03ug7RnjTIdJY/n2p7t59kLeAB3tqiczhcj+Oi5w=="],
"@react-stately/flags": ["@react-stately/flags@3.1.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg=="],
"@react-stately/utils": ["@react-stately/utils@3.10.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA=="],
"@react-types/shared": ["@react-types/shared@3.29.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-IDQYu/AHgZimObzCFdNl1LpZvQW/xcfLt3v20sorl5qRucDVj4S9os98sVTZ4IRIBjmS+MkjqpR5E70xan7ooA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.0", "", { "os": "android", "cpu": "arm64" }, "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ=="],
"@swc/core": ["@swc/core@1.11.22", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.21" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.11.22", "@swc/core-darwin-x64": "1.11.22", "@swc/core-linux-arm-gnueabihf": "1.11.22", "@swc/core-linux-arm64-gnu": "1.11.22", "@swc/core-linux-arm64-musl": "1.11.22", "@swc/core-linux-x64-gnu": "1.11.22", "@swc/core-linux-x64-musl": "1.11.22", "@swc/core-win32-arm64-msvc": "1.11.22", "@swc/core-win32-ia32-msvc": "1.11.22", "@swc/core-win32-x64-msvc": "1.11.22" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-mjPYbqq8XjwqSE0hEPT9CzaJDyxql97LgK4iyvYlwVSQhdN1uK0DBG4eP9PxYzCS2MUGAXB34WFLegdUj5HGpg=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.11.22", "", { "os": "darwin", "cpu": "arm64" }, "sha512-upSiFQfo1TE2QM3+KpBcp5SrOdKKjoc+oUoD1mmBDU2Wv4Bjjv16Z2I5ADvIqMV+b87AhYW+4Qu6iVrQD7j96Q=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.11.22", "", { "os": "darwin", "cpu": "x64" }, "sha512-8PEuF/gxIMJVK21DjuCOtzdqstn2DqnxVhpAYfXEtm3WmMqLIOIZBypF/xafAozyaHws4aB/5xmz8/7rPsjavw=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.11.22", "", { "os": "linux", "cpu": "arm" }, "sha512-NIPTXvqtn9e7oQHgdaxM9Z/anHoXC3Fg4ZAgw5rSGa1OlnKKupt5sdfJamNggSi+eAtyoFcyfkgqHnfe2u63HA=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.11.22", "", { "os": "linux", "cpu": "arm64" }, "sha512-xZ+bgS60c5r8kAeYsLNjJJhhQNkXdidQ277pUabSlu5GjR0CkQUPQ+L9hFeHf8DITEqpPBPRiAiiJsWq5eqMBg=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.11.22", "", { "os": "linux", "cpu": "arm64" }, "sha512-JhrP/q5VqQl2eJR0xKYIkKTPjgf8CRsAmRnjJA2PtZhfQ543YbYvUqxyXSRyBOxdyX8JwzuAxIPEAlKlT7PPuQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.11.22", "", { "os": "linux", "cpu": "x64" }, "sha512-htmAVL+U01gk9GyziVUP0UWYaUQBgrsiP7Ytf6uDffrySyn/FclUS3MDPocNydqYsOpj3OpNKPxkaHK+F+X5fg=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.11.22", "", { "os": "linux", "cpu": "x64" }, "sha512-PL0VHbduWPX+ANoyOzr58jBiL2VnD0xGSFwPy7NRZ1Pr6SNWm4jw3x2u6RjLArGhS5EcWp64BSk9ZxqmTV3FEg=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.11.22", "", { "os": "win32", "cpu": "arm64" }, "sha512-moJvFhhTVGoMeEThtdF7hQog80Q00CS06v5uB+32VRuv+I31+4WPRyGlTWHO+oY4rReNcXut/mlDHPH7p0LdFg=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.11.22", "", { "os": "win32", "cpu": "ia32" }, "sha512-/jnsPJJz89F1aKHIb5ScHkwyzBciz2AjEq2m9tDvQdIdVufdJ4SpEDEN9FqsRNRLcBHjtbLs6bnboA+B+pRFXw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.11.22", "", { "os": "win32", "cpu": "x64" }, "sha512-lc93Y8Mku7LCFGqIxJ91coXZp2HeoDcFZSHCL90Wttg5xhk5xVM9uUCP+OdQsSsEixLF34h5DbT9ObzP8rAdRw=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@swc/types": ["@swc/types@0.1.21", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.6", "", { "dependencies": { "@tanstack/virtual-core": "3.13.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.6", "", {}, "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="],
"@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="],
"@types/react": ["@types/react@18.3.20", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg=="],
"@types/react-dom": ["@types/react-dom@18.3.6", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.31.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.31.0", "@typescript-eslint/type-utils": "8.31.0", "@typescript-eslint/utils": "8.31.0", "@typescript-eslint/visitor-keys": "8.31.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.31.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.31.0", "@typescript-eslint/types": "8.31.0", "@typescript-eslint/typescript-estree": "8.31.0", "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.31.0", "", { "dependencies": { "@typescript-eslint/types": "8.31.0", "@typescript-eslint/visitor-keys": "8.31.0" } }, "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.31.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.31.0", "@typescript-eslint/utils": "8.31.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.31.0", "", {}, "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.31.0", "", { "dependencies": { "@typescript-eslint/types": "8.31.0", "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.31.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.31.0", "@typescript-eslint/types": "8.31.0", "@typescript-eslint/typescript-estree": "8.31.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.31.0", "", { "dependencies": { "@typescript-eslint/types": "8.31.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.9.0", "", { "dependencies": { "@swc/core": "^1.11.21" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" } }, "sha512-jYFUSXhwMCYsh/aQTgSGLIN3Foz5wMbH9ahb0Zva//UzwZYbMiZd7oT3AU9jHT9DLswYDswsRwPU9jVF3yA48Q=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001715", "", {}, "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.142", "", {}, "sha512-Ah2HgkTu/9RhTDNThBtzu2Wirdy4DC9b0sMT1pUhbkZQ5U/iwmE+PHZX1MpjD5IkJCc2wSghgGG/B04szAx07w=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.25.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.13.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.25.1", "@eslint/plugin-kit": "^0.2.8", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
"eslint-scope": ["eslint-scope@8.3.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
"espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="],
"postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.11", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.40.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.0", "@rollup/rollup-android-arm64": "4.40.0", "@rollup/rollup-darwin-arm64": "4.40.0", "@rollup/rollup-darwin-x64": "4.40.0", "@rollup/rollup-freebsd-arm64": "4.40.0", "@rollup/rollup-freebsd-x64": "4.40.0", "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", "@rollup/rollup-linux-arm-musleabihf": "4.40.0", "@rollup/rollup-linux-arm64-gnu": "4.40.0", "@rollup/rollup-linux-arm64-musl": "4.40.0", "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-musl": "4.40.0", "@rollup/rollup-linux-s390x-gnu": "4.40.0", "@rollup/rollup-linux-x64-gnu": "4.40.0", "@rollup/rollup-linux-x64-musl": "4.40.0", "@rollup/rollup-win32-arm64-msvc": "4.40.0", "@rollup/rollup-win32-ia32-msvc": "4.40.0", "@rollup/rollup-win32-x64-msvc": "4.40.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.31.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/parser": "8.31.0", "@typescript-eslint/utils": "8.31.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.18", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

Binary file not shown.

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="color-scheme" content="light dark">
{% if theme == 'neutral' %}
<meta
name="theme-color"
@@ -26,9 +27,9 @@
content="#030712"
>
{% endif %}
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
<title>Cup</title>
</head>
<body>
@@ -225,182 +226,97 @@
</div>
<ul>
{% for server in server_ids %}
{% if server == '' %}
<li class="mb-8 last:mb-0">
<ul
class="dark:divide-{{theme}}-900 divide-y dark:text-white"
>
{% for image in servers[server] %}
<li
class="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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-6 shrink-0 text-{{ theme }}-500"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
<span class="font-mono">{{ image.name }}</span>
{% case image.status %}
{% when 'Up to
date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto text-green-500"
>
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
{% when 'Unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-{{ theme }}-500 ml-auto"
>
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>
</svg>
{% else %}
{% case image.status %}
{% when 'Major update' %}
{% assign color = 'text-red-500' %}
{% when 'Minor
update' %}
{% assign color = 'text-yellow-500' %}
{% else %}
{% assign color = 'text-blue-500' %}
{% endcase %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto {{ color }}"
>
<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>
</svg>
{% endcase %}
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="mb-4 last:mb-0">
<p
class="my-4 text-lg font-semibold text-{{ theme }}-600 dark:text-{{ theme }}-400 px-6"
>
<li class="mb-4 last:mb-0">
<p
class="my-4 text-lg font-semibold text-{{ theme }}-600 dark:text-{{ theme }}-400 px-6"
>
{% if server == '' %}
Local images
{% else %}
{{ server }}
</p>
<ul
class="dark:divide-{{ theme }}-900 divide-y dark:text-white"
>
{% for image in servers[server] %}
<li
class="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"
{% endif %}
</p>
<ul
class="dark:divide-{{theme}}-900 divide-y dark:text-white"
>
{% for image in servers[server] %}
<li
class="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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-6 shrink-0 text-{{ theme }}-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-6 shrink-0"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
{{ image.name }}
{% case image.status %}
{% when 'Up to
date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto text-green-500"
>
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
{% when 'Unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-{{ theme }}-500 ml-auto"
>
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>
</svg>
{% else %}
{% case image.status %}
{% when 'Major update' %}
{% assign color = 'text-red-500' %}
{% when 'Minor
update' %}
{% assign color = 'text-yellow-500' %}
{% else %}
{% assign color = 'text-blue-500' %}
{% endcase %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto {{ color }}"
>
<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>
</svg>
{% endcase %}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
<span class="font-mono">{{ image.name }}</span>
{% case image.status %}
{% when 'Up to date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto text-green-500"
>
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
{% when 'Unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-{{ theme }}-500 ml-auto"
>
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>
</svg>
{% else %}
{% case image.status %}
{% when 'Major update' %}
{% assign color = 'text-red-500' %}
{% when 'Minor update' %}
{% assign color = 'text-yellow-500' %}
{% else %}
{% assign color = 'text-blue-500' %}
{% endcase %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-auto {{ color }}"
>
<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>
</svg>
{% endcase %}
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</div>

View File

@@ -12,8 +12,8 @@
},
"dependencies": {
"@headlessui/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.2",
"caniuse-lite": "^1.0.30001698",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.475.0",
@@ -24,7 +24,6 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^22.5.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
@@ -41,4 +40,4 @@
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}
}

View File

@@ -4,11 +4,17 @@ import Statistic from "./components/Statistic";
import Image from "./components/Image";
import { LastChecked } from "./components/LastChecked";
import Loading from "./components/Loading";
import { Data } from "./types";
import { Filters as FiltersType } from "./types";
import { theme } from "./theme";
import RefreshButton from "./components/RefreshButton";
import Search from "./components/Search";
import { Server } from "./components/Server";
import { useData } from "./hooks/use-data";
import DataLoadingError from "./components/DataLoadingError";
import Filters from "./components/Filters";
import { Filter, FilterX } from "lucide-react";
import { WithTooltip } from "./components/ui/Tooltip";
import { getDescription } from "./utils";
const SORT_ORDER = [
"monitored_images",
@@ -22,9 +28,25 @@ const SORT_ORDER = [
];
function App() {
const [data, setData] = useState<Data | null>(null);
const { data, isLoading, isError } = useData();
const [showFilters, setShowFilters] = useState<boolean>(false);
const [filters, setFilters] = useState<FiltersType>({
onlyInUse: false,
registries: [],
statuses: [],
});
const [searchQuery, setSearchQuery] = useState("");
if (!data) return <Loading onLoad={setData} />;
if (isLoading) return <Loading />;
if (isError || !data) return <DataLoadingError />;
const toggleShowFilters = () => {
if (showFilters) {
setFilters({ onlyInUse: false, registries: [], statuses: [] });
}
setShowFilters(!showFilters);
};
return (
<div
className={`flex min-h-screen justify-center bg-white dark:bg-${theme}-950`}
@@ -58,18 +80,38 @@ function App() {
className={`border shadow-sm border-${theme}-200 dark:border-${theme}-900 my-8 rounded-md`}
>
<div
className={`flex items-center justify-between px-6 py-4 text-${theme}-500`}
className={`flex items-center justify-between gap-3 px-6 py-4 text-${theme}-500`}
>
<LastChecked datetime={data.last_updated} />
<RefreshButton />
<div className="flex gap-3">
<WithTooltip
text={showFilters ? "Clear filters" : "Show filters"}
>
<button onClick={toggleShowFilters}>
{showFilters ? <FilterX /> : <Filter />}
</button>
</WithTooltip>
<RefreshButton />
</div>
</div>
<Search onChange={setSearchQuery} />
<div className="flex gap-2 px-6 text-black dark:text-white">
<Search onChange={setSearchQuery} />
</div>
{showFilters && (
<Filters
filters={filters}
setFilters={setFilters}
registries={[
...new Set(data.images.map((image) => image.parts.registry)),
]}
/>
)}
<ul>
{Object.entries(
data.images.reduce<Record<string, typeof data.images>>(
(acc, image) => {
const server = image.server ?? "";
if (!acc[server]) acc[server] = [];
if (!Object.hasOwn(acc, server)) acc[server] = [];
acc[server].push(image);
return acc;
},
@@ -80,6 +122,19 @@ function App() {
.map(([server, images]) => (
<Server name={server} key={server}>
{images
.filter((image) =>
filters.onlyInUse ? image.used_by.length > 0 : true,
)
.filter((image) =>
filters.registries.length == 0
? true
: filters.registries.includes(image.parts.registry),
)
.filter((image) =>
filters.statuses.length == 0
? true
: filters.statuses.includes(getDescription(image)),
)
.filter((image) => image.reference.includes(searchQuery))
.map((image) => (
<Image data={image} key={image.reference} />

View File

@@ -0,0 +1,13 @@
import { theme } from "../theme";
import { ReactNode } from "react";
import { cn } from "../utils";
export default function Badge({ children, className }: { children: ReactNode, className?: string }) {
return (
<span
className={cn(`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`, className)}
>
{children}
</span>
);
}

View File

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

View File

@@ -0,0 +1,30 @@
import Logo from "./Logo";
import { theme } from "../theme";
const DataLoadingError = () => {
return (
<div
className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="absolute mx-auto h-full w-full max-w-[80rem] overflow-hidden px-4 sm:px-6 lg:px-8">
<div className="mx-auto my-8 flex h-full max-w-[48rem] flex-col">
<div className="flex items-center gap-1">
<h1 className="text-5xl font-bold lg:text-6xl dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`flex h-full flex-col items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
<div className="mb-8 flex gap-1">
An error occurred, please try again.
</div>
</div>
</div>
</div>
</div>
);
};
export default DataLoadingError;

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { theme } from "../theme";
import { Filters as FiltersType } from "../types";
import { Checkbox } from "./ui/Checkbox";
import Select from "./ui/Select";
import { Server } from "lucide-react";
interface Props {
filters: FiltersType;
setFilters: (filters: FiltersType) => void;
registries: string[];
}
const STATUSES = [
"Major update",
"Minor update",
"Patch update",
"Digest update",
"Up to date",
"Unknown",
];
export default function Filters({ filters, setFilters, registries }: Props) {
const [selectedRegistries, setSelectedRegistries] = useState<
FiltersType["registries"]
>([]);
const [selectedStatuses, setSelectedStatuses] = useState<
FiltersType["statuses"]
>([]);
const handleSelectRegistries = (registries: string[]) => {
setSelectedRegistries(registries);
setFilters({
...filters,
registries,
});
};
const handleSelectStatuses = (statuses: string[]) => {
if (statuses.every((status) => STATUSES.includes(status))) {
setSelectedStatuses(statuses as FiltersType["statuses"]);
setFilters({
...filters,
statuses: statuses as FiltersType["statuses"],
});
}
};
return (
<div className="flex w-full flex-col gap-4 px-6 py-4 sm:flex-row">
<div className="flex items-center space-x-2">
<Checkbox
id="inUse"
checked={filters.onlyInUse}
onCheckedChange={(value) => {
setFilters({
...filters,
onlyInUse: value === "indeterminate" ? false : value,
});
}}
/>
<label
htmlFor="inUse"
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`}
>
Hide unused images
</label>
</div>
<Select
Icon={Server}
items={registries}
placeholder="Registry"
selectedItems={selectedRegistries}
setSelectedItems={handleSelectRegistries}
/>
<Select
items={STATUSES}
placeholder="Update type"
selectedItems={selectedStatuses}
setSelectedItems={handleSelectStatuses}
/>
</div>
);
}

View File

@@ -5,19 +5,23 @@ import {
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { WithTooltip } from "./Tooltip";
import { WithTooltip } from "./ui/Tooltip";
import type { Image } from "../types";
import { theme } from "../theme";
import { CodeBlock } from "./CodeBlock";
import {
ArrowRight,
Box,
CircleArrowUp,
CircleCheck,
Container,
HelpCircle,
Timer,
TriangleAlert,
X,
} from "lucide-react";
import Badge from "./Badge";
import { getDescription, truncateArray } from "../utils";
const clickable_registries = [
"registry-1.docker.io",
@@ -38,8 +42,11 @@ export default function Image({ data }: { data: Image }) {
data.result.info?.type == "version"
? data.reference.split(":")[0] + ":" + data.result.info.new_tag
: data.reference;
const info = getInfo(data);
let url: string | null = null;
if (clickable_registries.includes(data.parts.registry)) {
if (data.url) {
url = data.url;
} else if (clickable_registries.includes(data.parts.registry)) {
switch (data.parts.registry) {
case "registry-1.docker.io":
url = `https://hub.docker.com/r/${data.parts.repository}`;
@@ -57,7 +64,25 @@ export default function Image({ data }: { data: Image }) {
>
<Box className={`size-6 shrink-0 text-${theme}-500`} />
<span className="font-mono">{data.reference}</span>
<Icon data={data} />
<div className="ml-auto flex gap-2">
{data.result.info?.type === "version" ? (
<Badge className="hidden sm:inline-flex">
{data.result.info.current_version}
<ArrowRight className="size-3" />
{data.result.info.new_version}
</Badge>
) : null}
<Badge className="hidden md:inline-flex">
<Container className="size-4 mr-1"/>
{data.used_by.length}
</Badge>
<WithTooltip
text={info.description}
className={`size-6 shrink-0 ${info.color}`}
>
<info.icon />
</WithTooltip>
</div>
</li>
</button>
<Dialog open={open} onClose={setOpen} className="relative z-10">
@@ -76,14 +101,14 @@ export default function Image({ data }: { data: Image }) {
>
<div className="mb-4 flex items-center gap-3">
<Box className={`size-6 shrink-0 text-${theme}-500`} />
<DialogTitle className="font-mono text-black dark:text-white">
<DialogTitle className="break-all font-mono text-black dark:text-white">
{url ? (
<>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={`group w-fit text-black hover:underline dark:text-white`}
className={`group w-fit hover:underline`}
>
<span>
{data.reference}
@@ -113,7 +138,8 @@ export default function Image({ data }: { data: Image }) {
</button>
</div>
<div className="flex items-center gap-3">
<DialogIcon data={data} />
<info.icon className={`size-6 shrink-0 ${info.color}`} />
{info.description}
</div>
<div className="flex items-center gap-3">
<Timer className="size-6 shrink-0 text-gray-500" />
@@ -121,6 +147,21 @@ export default function Image({ data }: { data: Image }) {
Checked in <b>{data.time}</b> ms
</span>
</div>
{data.used_by.length !== 0 && (
<div className="flex items-start gap-3">
<Container className="size-6 shrink-0 text-gray-500" />
<Disclosure as="div">
<DisclosureButton className="inline-flex items-end group">Used by {truncateArray(data.used_by)}<ChevronDown className="shrink-0 size-5 group-data-[open]:rotate-180"/></DisclosureButton>
<DisclosurePanel className="origin-top transition duration-200 ease-out data-[closed]:-translate-y-6 data-[closed]:opacity-0 mt-4 rounded-lg bg-black/50 px-3 py-2" transition>
<table>
<tbody className="divide-y divide-white/10">
{data.used_by.map((container) => <tr className="divide divide-white/10 divide-x group"><td className="px-2 py-1 group-first:pt-2 group-last:pb-2"><pre>{container}</pre></td><td className="px-2 py-1 group-first:pt-2 group-last:pb-2"><button className="inline-flex gap-1 items-center">Update<CircleArrowUp className="size-5"/></button></td></tr>)}
</tbody>
</table>
</DisclosurePanel>
</Disclosure>
</div>
)}
{data.result.error && (
<div className="break-before mt-4 flex items-center gap-3 overflow-hidden rounded-md bg-yellow-400/10 px-3 py-2">
<TriangleAlert className="size-6 shrink-0 text-yellow-500" />
@@ -164,118 +205,49 @@ export default function Image({ data }: { data: Image }) {
);
}
function Icon({ data }: { data: Image }) {
switch (data.result.has_update) {
case null:
return (
<WithTooltip
text="Unknown"
className="ml-auto size-6 shrink-0 text-gray-500"
>
<HelpCircle />
</WithTooltip>
);
case false:
return (
<WithTooltip
text="Up to date"
className="ml-auto size-6 shrink-0 text-green-500"
>
<CircleCheck />
</WithTooltip>
);
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return (
<WithTooltip
text="Major Update"
className="ml-auto size-6 shrink-0 text-red-500"
>
<CircleArrowUp />
</WithTooltip>
);
case "minor":
return (
<WithTooltip
text="Minor Update"
className="ml-auto size-6 shrink-0 text-yellow-500"
>
<CircleArrowUp />
</WithTooltip>
);
case "patch":
return (
<WithTooltip
text="Patch Update"
className="ml-auto size-6 shrink-0 text-blue-500"
>
<CircleArrowUp />
</WithTooltip>
);
}
} else if (data.result.info?.type === "digest") {
return (
<WithTooltip
text="Update available"
className="ml-auto size-6 shrink-0 text-blue-500"
>
<CircleArrowUp />
</WithTooltip>
);
}
}
}
function getInfo(data: Image): {
color: string;
icon: typeof HelpCircle;
description: string;
} {
const description = getDescription(data);
switch (description) {
case "Unknown":
return {
color: "text-gray-500",
icon: HelpCircle,
description,
};
case "Up to date":
return {
color: "text-green-500",
icon: CircleCheck,
description,
};
function DialogIcon({ data }: { data: Image }) {
switch (data.result.has_update) {
case null:
return (
<>
<HelpCircle className="size-6 shrink-0 text-gray-500" />
Unknown
</>
);
case false:
return (
<>
<CircleCheck className="size-6 shrink-0 text-green-500" />
Up to date
</>
);
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return (
<>
<CircleArrowUp className="size-6 shrink-0 text-red-500" />
Major update
</>
);
case "minor":
return (
<>
<CircleArrowUp className="size-6 shrink-0 text-yellow-500" />
Minor update
</>
);
case "patch":
return (
<>
<CircleArrowUp className="size-6 shrink-0 text-blue-500" />
Patch update
</>
);
}
} else if (data.result.info?.type === "digest") {
return (
<>
<CircleArrowUp className="size-6 shrink-0 text-blue-500" />
Update available
</>
);
}
case "Major update":
return {
color: "text-red-500",
icon: CircleArrowUp,
description,
};
case "Minor update":
return {
color: "text-yellow-500",
icon: CircleArrowUp,
description,
};
case "Patch update":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description,
};
case "Digest update":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description,
};
}
}

View File

@@ -1,18 +1,8 @@
import { Data } from "../types";
import Logo from "./Logo";
import { theme } from "../theme";
import { RefreshCw } from "lucide-react";
import { LoaderCircle } from "lucide-react";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
fetch(
process.env.NODE_ENV === "production"
? "/api/v3/json"
: `http://${window.location.hostname}:8000/api/v3/json`,
).then((response) =>
response.json().then((data) => {
onLoad(data as Data);
}),
);
export default function Loading() {
return (
<div
className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`}
@@ -26,9 +16,16 @@ export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
<Logo />
</div>
<div
className={`flex h-full items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
className={`flex h-full flex-col items-center justify-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
Loading <RefreshCw className="animate-spin" />
<div className="mb-8 flex gap-1">
Loading <LoaderCircle className="animate-spin" />
</div>
<p>
If this takes more than a few seconds, there was probably a
problem fetching the data. Please try reloading the page and
report a bug if the problem persists.
</p>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { WithTooltip } from "./Tooltip";
import { WithTooltip } from "./ui/Tooltip";
export default function RefreshButton() {
const [disabled, setDisabled] = useState(false);
@@ -14,14 +14,14 @@ export default function RefreshButton() {
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/api/v3/refresh"
? "./api/v3/refresh"
: `http://${window.location.hostname}:8000/api/v3/refresh`,
);
request.send();
};
return (
<WithTooltip text="Reload">
<button className="group" onClick={refresh} disabled={disabled}>
<button className="group shrink-0" onClick={refresh} disabled={disabled}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
@@ -32,7 +32,7 @@ export default function RefreshButton() {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 group-disabled:animate-spin"
className="size-6 group-disabled:animate-spin"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />

View File

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

View File

@@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../utils";
import { theme } from "../../theme";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
`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`,
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check
className={`h-3 w-3 group-data-[state=checked]:text-white dark:group-data-[state=checked]:text-${theme}-950`}
strokeWidth={3}
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,83 @@
import {
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from "@headlessui/react";
import { ChevronDown, Check } from "lucide-react";
import { theme } from "../../theme";
import { cn, truncateArray } from "../../utils";
import { Server } from "lucide-react";
export default function Select({
items,
Icon,
placeholder,
selectedItems,
setSelectedItems,
}: {
items: string[];
Icon?: typeof Server;
placeholder: string;
selectedItems: string[];
setSelectedItems: (items: string[]) => void;
}) {
return (
<Listbox value={selectedItems} onChange={setSelectedItems} multiple>
<div className="relative">
<ListboxButton
className={cn(
`flex w-full gap-2 overflow-x-hidden 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`,
selectedItems.length == 0
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
: "text-black dark:text-white",
)}
>
{Icon && (
<Icon
className={cn(
"size-4 shrink-0",
selectedItems.length == 0
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
: "text-black dark:text-white",
)}
/>
)}
<span className="truncate">
{selectedItems.length == 0
? placeholder
: truncateArray(selectedItems)}
</span>
<ChevronDown
aria-hidden="true"
className={`ml-auto size-5 shrink-0 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`}
/>
<div
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)]"
style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
></div>
</ListboxButton>
<ListboxOptions
transition
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`}
>
{items.map((item) => (
<ListboxOption
key={item}
value={item}
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`}
>
{item}
<span
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`}
>
<Check aria-hidden="true" className="size-4" />
</span>
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
);
}

View File

@@ -1,7 +1,7 @@
import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip";
import { cn } from "../utils";
import { cn } from "../../utils";
import { forwardRef, ReactNode } from "react";
import { theme } from "../theme";
import { theme } from "../../theme";
const TooltipContent = forwardRef<
React.ElementRef<typeof Content>,

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import type { Data } from "../types";
export const useData = () => {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<Data | null>(null);
useEffect(() => {
if (isLoading || isError || !!data) return;
setIsLoading(true);
setIsError(false);
setData(null);
fetch(
process.env.NODE_ENV === "production"
? "./api/v3/json"
: `http://${window.location.hostname}:8000/api/v3/json`,
)
.then((response) => {
if (response.ok) return response.json();
throw new Error("Failed to fetch data");
})
.then((data) => {
setData(data as Data);
})
.catch((error: unknown) => {
setIsError(true);
console.error(error);
})
.finally(() => {
setIsLoading(false);
});
}, [data, isError, isLoading]);
return {
data,
isLoading,
isError,
};
};

View File

@@ -20,6 +20,7 @@ export interface Image {
repository: string;
tag: string;
};
url: string | null;
result: {
has_update: boolean | null;
info: VersionInfo | DigestInfo | null;
@@ -27,6 +28,7 @@ export interface Image {
};
time: number;
server: string | null;
used_by: string[];
}
interface VersionInfo {
@@ -42,3 +44,16 @@ interface DigestInfo {
local_digests: string[];
remote_digest: string;
}
export interface Filters {
onlyInUse: boolean;
registries: string[];
statuses: (
| "Major update"
| "Minor update"
| "Patch update"
| "Digest update"
| "Up to date"
| "Unknown"
)[];
}

View File

@@ -1,6 +1,38 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type { Image } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getDescription(image: Image) {
switch (image.result.has_update) {
case null:
return "Unknown";
case false:
return "Up to date";
case true:
if (image.result.info?.type === "version") {
switch (image.result.info.version_update_type) {
case "major":
return "Major update";
case "minor":
return "Minor update";
case "patch":
return "Patch update";
}
} else if (image.result.info?.type === "digest") {
return "Digest update";
}
return "Unknown";
}
}
export function truncateArray(arr: string[]) {
if (arr.length > 1) {
return `${arr[0]} +${(arr.length - 1).toString()} more`
} else if (arr.length == 1) {
return arr[0]
}
}

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/App.tsx", "./src/components/*.tsx", "./index.liquid"],
content: ["./src/App.tsx", "./src/components/**/*.tsx", "./index.liquid"],
theme: {
extend: {},
},
@@ -15,7 +15,7 @@ export default {
variants: ["hover"],
},
{
pattern: /bg-(gray|neutral)-(900|950)/,
pattern: /bg-(gray|neutral)-(400|900|950)/,
variants: ["dark"],
},
{
@@ -27,27 +27,23 @@ export default {
variants: ["before:dark", "after:dark", "dark", "hover:dark"],
},
{
pattern: /text-(gray|neutral)-(50|300|200)/,
pattern: /text-(gray|neutral)-(50|300|200|400)/,
variants: ["dark"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["dark", "hover"],
variants: ["*", "dark", "hover", "placeholder", "data-[placeholder]"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["dark", "dark:hover"],
variants: ["*:dark", "dark", "dark:hover", "placeholder:dark", "data-[placeholder]:dark"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["placeholder"],
pattern: /text-(gray|neutral)-(500|700)/,
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["placeholder:dark"],
},
{
pattern: /text-(gray|neutral)-700/,
pattern: /text-(gray|neutral)-950/,
variants: ["dark:group-data-[state=checked]"]
},
{
pattern: /text-(gray|neutral)-800/,
@@ -62,10 +58,17 @@ export default {
variants: ["dark"],
},
{
pattern: /border-(gray|neutral)-(200|300)/,
pattern: /border-(gray|neutral)-(600|300|400)/,
},
{
pattern: /border-(gray|neutral)-(700|800|900)/,
pattern: /border-(gray|neutral)-(400|700|800|900)/,
variants: ["dark"],
},
{
pattern: /ring-(gray|neutral)-700/,
},
{
pattern: /ring-(gray|neutral)-400/,
variants: ["dark"],
},
],

View File

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