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

35 Commits
v3.2.2 ... v4

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
48 changed files with 3865 additions and 552 deletions

View File

@@ -14,13 +14,8 @@ jobs:
- name: Set up Rust - name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1 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 - name: Set up Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v2
- name: Install deps - name: Install deps
run: cd web && bun install run: cd web && bun install

View File

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

View File

@@ -32,13 +32,8 @@ jobs:
- name: Install cross - name: Install cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/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 - name: Set up Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v2
- name: Install deps - name: Install deps
run: cd web && bun install run: cd web && bun install

View File

@@ -30,13 +30,8 @@ jobs:
- name: Install cross - name: Install cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/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 - name: Set up Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v2
- name: Install deps - name: Install deps
run: cd web && bun install run: cd web && bun install

View File

@@ -7,7 +7,7 @@ First of all, thanks for taking time to contribute to Cup! This guide will help
Requirements: Requirements:
- A computer running Linux - A computer running Linux
- Rust (usually installed from https://rustup.rs/) - 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. 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 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

98
Cargo.lock generated
View File

@@ -164,9 +164,9 @@ dependencies = [
[[package]] [[package]]
name = "bollard" name = "bollard"
version = "0.18.1" version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" checksum = "af706e9dc793491dd382c99c22fde6e9934433d4cc0d6a4b34eb2cdc57a5c917"
dependencies = [ dependencies = [
"base64", "base64",
"bollard-stubs", "bollard-stubs",
@@ -197,11 +197,12 @@ dependencies = [
[[package]] [[package]]
name = "bollard-stubs" name = "bollard-stubs"
version = "1.47.1-rc.27.3.1" version = "1.48.2-rc.28.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" checksum = "79cdf0fccd5341b38ae0be74b74410bdd5eceeea8876dc149a13edfe57e3b259"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json",
"serde_repr", "serde_repr",
"serde_with", "serde_with",
] ]
@@ -260,6 +261,27 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402"
dependencies = [
"parse-zoneinfo",
"phf_codegen",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.31" version = "4.5.31"
@@ -355,11 +377,13 @@ dependencies = [
[[package]] [[package]]
name = "cup" name = "cup"
version = "3.2.1" version = "3.4.1"
dependencies = [ dependencies = [
"bollard", "bollard",
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"envy",
"futures", "futures",
"http-auth", "http-auth",
"http-link", "http-link",
@@ -423,6 +447,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1192,6 +1225,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -1243,6 +1285,44 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@@ -1688,6 +1768,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@@ -2471,6 +2557,8 @@ checksum = "dd4f8f16791ea2a8845f617f1e87887f917835e0603d01f03a51e638b9613d0c"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",
"serde",
"serde_json",
"tokio", "tokio",
"xitca-http", "xitca-http",
"xitca-server", "xitca-server",

View File

@@ -1,15 +1,15 @@
[package] [package]
name = "cup" name = "cup"
version = "3.2.2" version = "3.4.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
clap = { version = "4.5.7", features = ["derive"] } clap = { version = "4.5.7", features = ["derive"] }
indicatif = { version = "0.17.8", optional = true } indicatif = { version = "0.17.8", optional = true }
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
xitca-web = { version = "0.6.2", optional = true } xitca-web = { version = "0.6.2", optional = true, features = ["json"]}
liquid = { version = "0.26.6", optional = true } liquid = { version = "0.26.6", optional = true }
bollard = "0.18.1" bollard = "0.19.0"
once_cell = "1.19.0" once_cell = "1.19.0"
http-auth = { version = "0.1.9", default-features = false } http-auth = { version = "0.1.9", default-features = false }
termsize = { version = "0.1.8", optional = true } termsize = { version = "0.1.8", optional = true }
@@ -25,6 +25,8 @@ itertools = "0.14.0"
serde_json = "1.0.133" serde_json = "1.0.133"
serde = "1.0.215" serde = "1.0.215"
tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true } tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true }
envy = "0.4.2"
chrono-tz = "0.10.3"
[features] [features]
default = ["server", "cli"] default = ["server", "cli"]

View File

@@ -1,18 +1,18 @@
### Build UI ### ### Build UI ###
FROM node:20 AS web FROM oven/bun:1-alpine AS web
# Install bun # Copy package.json and lockfile from web
RUN curl -fsSL https://bun.sh/install | bash
# Copy web folder
COPY ./web /web
WORKDIR /web WORKDIR /web
COPY ./web/package.json ./web/bun.lock ./
# Install requirements # Install requirements
RUN ~/.bun/bin/bun install RUN bun install
# Copy web folder
COPY ./web .
# Build frontend # Build frontend
RUN ~/.bun/bin/bun run build RUN bun run build
### Build Cup ### ### Build Cup ###
FROM rust:1-alpine AS build FROM rust:1-alpine AS build

View File

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

1661
docs/bun.lock Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.29.0", "@tabler/icons-react": "^3.29.0",
"geist": "^1.3.1", "geist": "^1.3.1",
"next": "15.2.3", "next": "15.2.4",
"nextra": "^4.1.0", "nextra": "^4.1.0",
"nextra-theme-docs": "^4.1.0", "nextra-theme-docs": "^4.1.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -21,7 +21,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.0.1", "@tailwindcss/postcss": "^4.0.1",
"@types/node": "^22.10.7", "@types/bun": "^1.2.10",
"@types/react": "^19.0.7", "@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@@ -32,6 +32,5 @@
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.1", "tailwindcss": "^4.0.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, }
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

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

View File

@@ -1,12 +1,18 @@
import { Callout } from "nextra/components";
# Automatic refresh # Automatic refresh
Cup can automatically refresh the results when running in server mode. Simply add this to your config: Cup can automatically refresh the results when running in server mode. Simply add this to your config:
```jsonc ```jsonc
{ {
"refresh_interval": "0 0,30 * * * *" // Check twice an hour "refresh_interval": "0 */30 * * * *", // Check twice an hour
// Other options // Other options
} }
``` ```
You can use a cron expression to specify the refresh interval. The reference is [here](https://github.com/Hexagon/croner-rust#pattern) You can use a cron expression to specify the refresh interval. Note that seconds are not optional. The reference is [here](https://github.com/Hexagon/croner-rust#pattern).
<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,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

@@ -109,3 +109,36 @@ $ docker run -tv /var/run/docker.sock:/var/run/docker.sock -v /home/sergio/.conf
``` ```
</Steps> </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

@@ -3,7 +3,7 @@ use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use crate::{ use crate::{
docker::get_images_from_docker_daemon, docker::{get_images_from_docker_daemon, get_in_use_images},
http::Client, http::Client,
registry::{check_auth, get_token}, registry::{check_auth, get_token},
structs::{image::Image, update::Update}, structs::{image::Image, update::Update},
@@ -80,6 +80,7 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
} }
/// Returns a list of updates for all images passed in. /// Returns a list of updates for all images passed in.
/// TODO: Completely rewrite this and make nothing is missed
pub async fn get_updates( pub async fn get_updates(
references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value
refresh: bool, refresh: bool,
@@ -90,15 +91,28 @@ pub async fn get_updates(
// Merge references argument with references from config // Merge references argument with references from config
let all_references = match &references { let all_references = match &references {
Some(refs) => { Some(refs) => {
refs.clone().extend_from_slice(&ctx.config.images.extra); 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 refs
} }
None => &ctx.config.images.extra, None => &ctx.config.extra_images,
}; };
// Get local images // Get local images
ctx.logger.debug("Retrieving images to be checked"); ctx.logger.debug("Retrieving images to be checked");
let mut images = get_images_from_docker_daemon(ctx, references).await; let mut images = get_images_from_docker_daemon(ctx, references).await;
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 // Add extra images from references
if !all_references.is_empty() { if !all_references.is_empty() {
@@ -130,13 +144,7 @@ pub async fn get_updates(
.map(|image| &image.parts.registry) .map(|image| &image.parts.registry)
.unique() .unique()
.filter(|&registry| match ctx.config.registries.get(registry) { .filter(|&registry| match ctx.config.registries.get(registry) {
Some(config) => { Some(config) => !config.ignore,
if config.ignore {
false
} else {
true
}
}
None => true, None => true,
}) })
.collect::<Vec<&String>>(); .collect::<Vec<&String>>();
@@ -201,7 +209,9 @@ pub async fn get_updates(
} }
// Await all the futures // Await all the futures
let images = join_all(handles).await; let images = join_all(handles).await;
let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect(); let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect();
updates.extend_from_slice(&remote_updates); updates.extend_from_slice(&remote_updates);
updates updates
} }

View File

@@ -1,15 +1,23 @@
use std::path::PathBuf;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::Deserialize; use serde::Deserialize;
use serde::Deserializer;
use std::env;
use std::mem;
use std::path::PathBuf;
use crate::error; 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)] #[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme { pub enum Theme {
#[serde(rename = "default")]
Default, Default,
#[serde(rename = "blue")]
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)] #[derive(Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(default)] #[serde(default)]
@@ -28,11 +51,21 @@ pub struct RegistryConfig {
pub ignore: bool, 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)] #[derive(Clone, Deserialize, Default)]
#[serde(default)] #[serde(default)]
pub struct ImageConfig { pub struct ImageConfig {
pub extra: Vec<String>, pub include: bool, // Takes precedence over extra_images and excluded_images
pub exclude: Vec<String>, pub tag_type: TagType,
pub ignore: UpdateType
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
@@ -40,7 +73,10 @@ pub struct ImageConfig {
pub struct Config { pub struct Config {
version: u8, version: u8,
pub agent: bool, 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 refresh_interval: Option<String>,
pub registries: FxHashMap<String, RegistryConfig>, pub registries: FxHashMap<String, RegistryConfig>,
pub servers: FxHashMap<String, String>, pub servers: FxHashMap<String, String>,
@@ -48,12 +84,15 @@ pub struct Config {
pub theme: Theme, 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 { impl Config {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
version: 3, version: 3,
agent: false, agent: false,
images: ImageConfig::default(), images: FxHashMap::default(),
extra_images: Vec::new(),
excluded_images: Vec::new(),
refresh_interval: None, refresh_interval: None,
registries: FxHashMap::default(), registries: FxHashMap::default(),
servers: 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. /// 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 { let raw_config = match &path {
Some(path) => std::fs::read_to_string(path), Some(path) => std::fs::read_to_string(path),
None => return Self::new(), // Empty config None => return Self::new(), // Empty config
@@ -77,13 +149,13 @@ impl Config {
self.parse(&raw_config.unwrap()) // We can safely unwrap here self.parse(&raw_config.unwrap()) // We can safely unwrap here
} }
/// Parses and validates the config. /// 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) { let config: Self = match serde_json::from_str(raw_config) {
Ok(config) => config, Ok(config) => config,
Err(e) => error!("Unexpected error occured while parsing config: {}", e), Err(e) => error!("Unexpected error occured while parsing config: {}", e),
}; };
if config.version != 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 3, or if you have already done so, add a `version` key with the value `3`.") 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 config
} }
@@ -94,3 +166,15 @@ impl Default for Config {
Self::new() 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 { fn create_docker_client(socket: Option<&str>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket { let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -42,7 +60,7 @@ pub async fn get_images_from_docker_daemon(
references: &Option<Vec<String>>, references: &Option<Vec<String>>,
) -> Vec<Image> { ) -> Vec<Image> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref()); let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let mut swarm_images = match client.list_services::<String>(None).await { let mut swarm_images = match client.list_services(None::<ListServicesOptions>).await {
Ok(services) => services Ok(services) => services
.iter() .iter()
.filter_map(|service| match &service.spec { .filter_map(|service| match &service.spec {
@@ -79,7 +97,7 @@ pub async fn get_images_from_docker_daemon(
.collect() .collect()
} }
None => { None => {
let images = match client.list_images::<String>(None).await { let images = match client.list_images(None::<ListImagesOptions>).await {
Ok(images) => images, Ok(images) => images,
Err(e) => { Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e) error!("Failed to retrieve list of images available!\n{}", e)
@@ -94,3 +112,240 @@ pub async fn get_images_from_docker_daemon(
local_images.append(&mut swarm_images); local_images.append(&mut swarm_images);
local_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, status::Status,
update::{Update, UpdateInfo}, 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) { pub fn print_updates(updates: &[Update], icons: &bool) {
@@ -124,11 +124,11 @@ pub fn print_updates(updates: &[Update], icons: &bool) {
Status::Unknown(_) => "\x1b[90m", Status::Unknown(_) => "\x1b[90m",
}; };
let description = format!( let description = format!(
"{} {}", "{}{}",
status, status,
match &update.result.info { match &update.result.info {
UpdateInfo::Version(info) => { UpdateInfo::Version(info) => {
format!("({}{})", info.current_version, info.new_version) format!(" ({}{})", info.current_version, info.new_version)
} }
_ => String::new(), _ => String::new(),
} }
@@ -164,5 +164,5 @@ pub fn print_updates(updates: &[Update], icons: &bool) {
} }
pub fn print_raw_updates(updates: &[Update]) { pub fn print_raw_updates(updates: &[Update]) {
println!("{}", to_simple_json(updates)); println!("{}", to_json(updates));
} }

View File

@@ -69,6 +69,10 @@ impl Client {
self.ctx.logger.warn(&message); self.ctx.logger.warn(&message);
Err(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 { } else if status.as_u16() <= 400 {
Ok(response) Ok(response)
} else { } else {

View File

@@ -1,14 +1,12 @@
use std::time::SystemTime; use std::{cmp::Ordering, time::SystemTime};
use itertools::Itertools; use itertools::Itertools;
use crate::{ use crate::{
config::UpdateType,
error, error,
http::Client, http::Client,
structs::{ structs::{image::Image, version::Version},
image::{DigestInfo, Image, VersionInfo},
version::Version,
},
utils::{ utils::{
link::parse_link, link::parse_link,
request::{ request::{
@@ -43,11 +41,11 @@ pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Optio
} }
pub async fn get_latest_digest( pub async fn get_latest_digest(
image: &Image, image: &mut Image,
token: Option<&str>, token: Option<&str>,
ctx: &Context, ctx: &Context,
client: &Client, client: &Client,
) -> Image { ) -> () {
ctx.logger ctx.logger
.debug(format!("Checking for digest update to {}", image.reference)); .debug(format!("Checking for digest update to {}", image.reference));
let start = SystemTime::now(); let start = SystemTime::now();
@@ -68,29 +66,17 @@ pub async fn get_latest_digest(
match response { match response {
Ok(res) => match res.headers().get("docker-content-digest") { Ok(res) => match res.headers().get("docker-content-digest") {
Some(digest) => { Some(digest) => {
let local_digests = match &image.digest_info { image.update_info.remote_digest = Some(digest.to_str().unwrap().to_owned());
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()
}
} }
None => error!( None => error!(
"Server returned invalid response! No docker-content-digest!\n{:#?}", "Server returned invalid response! No docker-content-digest!\n{:#?}",
res res
), ),
}, },
Err(error) => Image { Err(error) => {
error: Some(error), image.error = Some(error);
time_ms: image.time_ms + time, image.time_ms = image.time_ms + elapsed(start)
..image.clone() }
},
} }
} }
@@ -116,12 +102,12 @@ pub async fn get_token(
} }
pub async fn get_latest_tag( pub async fn get_latest_tag(
image: &Image, image: &mut Image,
base: &Version, base: &Version,
token: Option<&str>, token: Option<&str>,
ctx: &Context, ctx: &Context,
client: &Client, client: &Client,
) -> Image { ) -> () {
ctx.logger ctx.logger
.debug(format!("Checking for tag update to {}", image.reference)); .debug(format!("Checking for tag update to {}", image.reference));
let start = now(); let start = now();
@@ -149,24 +135,30 @@ pub async fn get_latest_tag(
&next_url.unwrap(), &next_url.unwrap(),
&headers, &headers,
base, base,
&image.version_info.as_ref().unwrap().format_str, &image.reference,
ctx,
client, client,
) )
.await .await
{ {
Ok(t) => t, Ok(t) => t,
Err(message) => { Err(message) => {
return Image { image.error = Some(message);
error: Some(message), image.time_ms += elapsed(start);
time_ms: image.time_ms + elapsed(start), return;
..image.clone()
}
} }
}; };
tags.extend_from_slice(&new_tags); tags.extend_from_slice(&new_tags);
next_url = next; 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!( ctx.logger.debug(format!(
"Checked for tag update to {} in {}ms", "Checked for tag update to {} in {}ms",
image.reference, image.reference,
@@ -174,35 +166,17 @@ pub async fn get_latest_tag(
)); ));
match tag { match tag {
Some(t) => { 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 // Tags are equal so we'll compare digests
ctx.logger.debug(format!( ctx.logger.debug(format!(
"Tags for {} are equal, comparing digests.", "Tags for {} are equal, comparing digests.",
image.reference image.reference
)); ));
get_latest_digest( image.time_ms += elapsed(start);
&Image { get_latest_digest(image, token, ctx, client).await
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
} else { } else {
Image { image.update_info.latest_version = Some(t.clone());
version_info: Some(VersionInfo { image.time_ms += elapsed(start);
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
time_ms: image.time_ms + elapsed(start),
..image.clone()
}
} }
} }
None => error!( None => error!(
@@ -216,11 +190,12 @@ pub async fn get_extra_tags(
url: &str, url: &str,
headers: &[(&str, Option<&str>)], headers: &[(&str, Option<&str>)],
base: &Version, base: &Version,
format_str: &str, reference: &str,
ctx: &Context,
client: &Client, client: &Client,
) -> Result<(Vec<Version>, Option<String>), String> { ) -> Result<(Vec<Version>, Option<String>), String> {
let response = client.get(url, &headers, false).await; let response = client.get(url, headers, false).await;
let base_type = base.r#type();
match response { match response {
Ok(res) => { Ok(res) => {
let next_url = res let next_url = res
@@ -232,15 +207,39 @@ pub async fn get_extra_tags(
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.filter_map(|tag| Version::from_tag(tag.as_str().unwrap())) .map(|tag| Version::from(tag.as_str().unwrap(), base_type.as_ref()))
.filter(|(tag, format_string)| match (base.minor, tag.minor) { .filter(|tag| tag.r#type() == base_type)
(Some(_), Some(_)) | (None, None) => { .filter(|tag| tag.partial_cmp(base).is_some())
matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None)) .filter_map(|tag| {
&& format_str == *format_string 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() .dedup()
.collect(); .collect();
Ok((result, next_url)) Ok((result, next_url))

View File

@@ -1,18 +1,20 @@
use std::sync::Arc; use std::{env, sync::Arc, time::SystemTime};
use chrono::Local; use chrono::Local;
use chrono_tz::Tz;
use liquid::{object, Object, ValueView}; use liquid::{object, Object, ValueView};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use tokio::sync::Mutex; use tokio::sync::RwLock;
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio_cron_scheduler::{Job, JobScheduler};
use xitca_web::{ use xitca_web::{
body::ResponseBody, body::ResponseBody,
bytes::Bytes, bytes::Bytes,
error::Error, error::Error,
handler::{handler_service, path::PathRef, state::StateRef}, handler::{handler_service, json::LazyJson, path::PathRef, state::StateRef},
http::{StatusCode, WebResponse}, http::{StatusCode, WebResponse},
route::get, route::{get, post},
service::Service, service::Service,
App, WebContext, App, WebContext,
}; };
@@ -20,10 +22,11 @@ use xitca_web::{
use crate::{ use crate::{
check::get_updates, check::get_updates,
config::Theme, config::Theme,
docker::upgrade_container,
error, error,
structs::update::Update, structs::update::Update,
utils::{ utils::{
json::{to_full_json, to_simple_json}, json::to_json,
sort_update_vec::sort_update_vec, sort_update_vec::sort_update_vec,
time::{elapsed, now}, time::{elapsed, now},
}, },
@@ -37,6 +40,10 @@ 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 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 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] = [ const SORT_ORDER: [&str; 8] = [
"monitored_images", "monitored_images",
"updates_available", "updates_available",
@@ -52,17 +59,24 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
ctx.logger.info("Starting server, please wait..."); ctx.logger.info("Starting server, please wait...");
let data = ServerData::new(ctx).await; let data = ServerData::new(ctx).await;
let scheduler = JobScheduler::new().await.unwrap(); 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 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 { if let Some(interval) = &ctx.config.refresh_interval {
scheduler scheduler
.add( .add(
match Job::new_async(interval, move |_uuid, _lock| { match Job::new_async_tz(
let data_copy = data_copy.clone(); interval,
Box::pin(async move { tz,
data_copy.lock().await.refresh().await; move |_uuid, _lock| {
}) let data_copy = data_copy.clone();
}) { Box::pin(async move {
data_copy.write().await.refresh().await;
})
},
) {
Ok(job) => job, Ok(job) => job,
Err(e) => match e { Err(e) => match e {
tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!( tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!(
@@ -83,10 +97,11 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
ctx.logger.info("Ready to start!"); ctx.logger.info("Ready to start!");
let mut app_builder = App::new() let mut app_builder = App::new()
.with_state(data) .with_state(data)
.at("/api/v2/json", get(handler_service(api_simple))) .at("/api/v3/json", get(handler_service(json)))
.at("/api/v3/json", get(handler_service(api_full))) .at("/api/v3/refresh", get(handler_service(refresh_v3)))
.at("/api/v2/refresh", get(handler_service(refresh))) .at("/api/v4/json", get(handler_service(json)))
.at("/api/v3/refresh", get(handler_service(refresh))); .at("/api/v4/refresh", get(handler_service(refresh_v4)))
.at("/api/v4/upgrade", post(handler_service(upgrade)));
if !ctx.config.agent { if !ctx.config.agent {
app_builder = app_builder app_builder = app_builder
.at("/", get(handler_service(_static))) .at("/", get(handler_service(_static)))
@@ -104,17 +119,17 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
.wait() .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 { match path.0 {
"/" => WebResponse::builder() "/" => WebResponse::builder()
.header("Content-Type", "text/html") .header("Content-Type", "text/html")
.body(ResponseBody::from(data.lock().await.template.clone())) .body(ResponseBody::from(data.read().await.template.clone()))
.unwrap(), .unwrap(),
"/assets/index.js" => WebResponse::builder() "/assets/index.js" => WebResponse::builder()
.header("Content-Type", "text/javascript") .header("Content-Type", "text/javascript")
.body(ResponseBody::from(JS.replace( .body(ResponseBody::from(JS.replace(
"=\"neutral\"", "=\"neutral\"",
&format!("=\"{}\"", data.lock().await.theme), &format!("=\"{}\"", data.read().await.theme),
))) )))
.unwrap(), .unwrap(),
"/assets/index.css" => WebResponse::builder() "/assets/index.css" => WebResponse::builder()
@@ -140,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() WebResponse::builder()
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(ResponseBody::from( .body(ResponseBody::from(
data.lock().await.simple_json.clone().to_string(), data.read().await.json.clone().to_string(),
)) ))
.unwrap() .unwrap()
} }
async fn api_full(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse { async fn refresh_v3(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
WebResponse::builder() data.write().await.refresh().await;
.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;
WebResponse::new(ResponseBody::from("OK")) 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 { struct ServerData {
template: String, template: String,
raw_updates: Vec<Update>, raw_updates: Vec<Update>,
simple_json: Value, json: Value,
full_json: Value,
ctx: Context, ctx: Context,
theme: &'static str, theme: &'static str,
} }
@@ -177,10 +218,12 @@ impl ServerData {
let mut s = Self { let mut s = Self {
ctx: ctx.clone(), ctx: ctx.clone(),
template: String::new(), template: String::new(),
simple_json: Value::Null, json: Value::Null,
full_json: Value::Null,
raw_updates: Vec::new(), raw_updates: Vec::new(),
theme: "neutral", theme: match ctx.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray",
},
}; };
s.refresh().await; s.refresh().await;
s s
@@ -202,19 +245,13 @@ impl ServerData {
.unwrap() .unwrap()
.parse(HTML) .parse(HTML)
.unwrap(); .unwrap();
self.simple_json = to_simple_json(&self.raw_updates); self.json = to_json(&self.raw_updates);
self.full_json = to_full_json(&self.raw_updates);
let last_updated = Local::now(); 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_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string() .to_string()
.into(); .into();
self.full_json["last_updated"] = self.simple_json["last_updated"].clone(); let mut metrics = self.json["metrics"]
self.theme = match &self.ctx.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray",
};
let mut metrics = self.simple_json["metrics"]
.as_object() .as_object()
.unwrap() .unwrap()
.iter() .iter()
@@ -264,17 +301,11 @@ where
let method = request.method().to_string(); let method = request.method().to_string();
let url = request.uri().to_string(); let url = request.uri().to_string();
if &method != "GET" { match (method.as_str(), url.as_str()) {
// We only allow GET requests ("POST", "/api/v4/upgrade") => continue_request(ctx, next, &method, &url, start).await,
("GET", "/api/v4/upgrade") | ("POST", _) => return_405(&method, &url, start).await,
log(&method, &url, 405, elapsed(start)); ("GET", _) => continue_request(ctx, next, &method, &url, start).await,
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED)) (_, _) => return_405(&method, &url, start).await,
} else {
let res = next.call(ctx).await?;
let status = res.status().as_u16();
log(&method, &url, status, elapsed(start));
Ok(res)
} }
} }
@@ -291,3 +322,29 @@ fn log(method: &str, url: &str, status: u16, time: u32) {
method, url, color, status, time 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, error,
http::Client, http::Client,
registry::{get_latest_digest, get_latest_tag}, 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, utils::reference::split,
Context, Context,
}; };
@@ -10,34 +10,35 @@ use crate::{
use super::{ use super::{
inspectdata::InspectData, inspectdata::InspectData,
parts::Parts, 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))] #[cfg_attr(test, derive(Debug))]
pub struct DigestInfo { pub struct Info {
pub local_digests: Vec<String>, 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)] /// Any new information obtained about the image
#[cfg_attr(test, derive(Debug))] #[derive(Debug, Clone, Default)]
pub struct VersionInfo { pub struct UpdateInfo {
pub current_tag: Version, pub remote_digest: Option<String>,
pub latest_remote_tag: Option<Version>, pub latest_version: Option<Version>
pub format_str: String,
} }
/// Image struct that contains all information that may be needed by a function working with an image. /// 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 /// It's designed to be passed around between functions
#[derive(Clone, PartialEq, Default)] #[derive(Clone, Default)]
#[cfg_attr(test, derive(Debug))] #[cfg_attr(test, derive(Debug))]
pub struct Image { pub struct Image {
pub reference: String, pub reference: String,
pub parts: Parts, pub parts: Parts,
pub url: Option<String>, pub info: Info,
pub digest_info: Option<DigestInfo>, pub update_info: UpdateInfo,
pub version_info: Option<VersionInfo>,
pub error: Option<String>, pub error: Option<String>,
pub time_ms: u32, pub time_ms: u32,
} }
@@ -53,7 +54,7 @@ impl Image {
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. 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 (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 let local_digests = digests
.iter() .iter()
.filter_map( .filter_map(
@@ -76,16 +77,7 @@ impl Image {
repository, repository,
tag, tag,
}, },
url: image.url(), info: Info { local_digests, version: version_tag, url: image.url(), used_by: Vec::new() },
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,
}),
..Default::default() ..Default::default()
}) })
} else { } else {
@@ -94,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. /// 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 (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 { 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(), reference: reference.to_string(),
parts: Parts { parts: Parts {
registry, registry,
repository, repository,
tag, tag,
}, },
version_info: Some(VersionInfo { info: Info { local_digests: Vec::new(), version: v, url: None, used_by: Vec::new() },
current_tag: version,
format_str,
latest_remote_tag: None,
}),
..Default::default() ..Default::default()
}, },
None => error!(
"Image {} is not available locally and does not have a recognizable tag format!",
reference
),
} }
} }
@@ -123,25 +111,18 @@ impl Image {
if self.error.is_some() { if self.error.is_some() {
Status::Unknown(self.error.clone().unwrap()) Status::Unknown(self.error.clone().unwrap())
} else { } else {
match &self.version_info { match self.update_info.latest_version {
Some(data) => data Some(latest_version) => latest_version.to_status(self.info.version),
.latest_remote_tag None => match self.update_info.remote_digest {
.as_ref() Some(remote_digest) => {
.unwrap() if self.info.local_digests.contains(&remote_digest) {
.to_status(&data.current_tag),
None => match &self.digest_info {
Some(data) => {
if data
.local_digests
.contains(data.remote_digest.as_ref().unwrap())
{
Status::UpToDate Status::UpToDate
} else { } else {
Status::UpdateAvailable Status::UpdateAvailable
} }
} },
None => unreachable!(), // I hope? None => unreachable!() // I hope?
}, }
} }
} }
} }
@@ -157,20 +138,14 @@ impl Image {
Update { Update {
reference: self.reference.clone(), reference: self.reference.clone(),
parts: self.parts.clone(), parts: self.parts.clone(),
url: self.url.clone(), url: self.info.url.clone(),
result: UpdateResult { result: UpdateResult {
has_update: has_update.to_option_bool(), has_update: has_update.to_option_bool(),
info: match has_update { info: match has_update {
Status::Unknown(_) => UpdateInfo::None, Status::Unknown(_) => crate::structs::update::UpdateInfo::None,
_ => match update_type { _ => match update_type {
"version" => { "version" => {
let (new_tag, format_str) = match &self.version_info { let update_info = &self.update_info.latest_version.unwrap().as_standard().unwrap();
Some(data) => (
data.latest_remote_tag.clone().unwrap(),
data.format_str.clone(),
),
_ => unreachable!(),
};
UpdateInfo::Version(VersionUpdateInfo { UpdateInfo::Version(VersionUpdateInfo {
version_update_type: match has_update { version_update_type: match has_update {
@@ -180,12 +155,12 @@ impl Image {
_ => unreachable!(), _ => unreachable!(),
} }
.to_string(), .to_string(),
new_tag: format_str new_tag: update_info.format_str
.replacen("{}", &new_tag.major.to_string(), 1) .replacen("{}", &update_info.major.to_string(), 1)
.replacen("{}", &new_tag.minor.unwrap_or(0).to_string(), 1) .replacen("{}", &update_info.minor.unwrap_or_default().to_string(), 1)
.replacen("{}", &new_tag.patch.unwrap_or(0).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 // 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 .version_info
.as_ref() .as_ref()
.unwrap() .unwrap()
@@ -221,6 +196,7 @@ impl Image {
}, },
time: self.time_ms, time: self.time_ms,
server: None, server: None,
used_by: self.used_by.clone(),
status: has_update, status: has_update,
} }
} }

View File

@@ -4,3 +4,4 @@ pub mod parts;
pub mod status; pub mod status;
pub mod update; pub mod update;
pub mod version; 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

@@ -11,6 +11,7 @@ pub struct Update {
pub result: UpdateResult, pub result: UpdateResult,
pub time: u32, pub time: u32,
pub server: Option<String>, pub server: Option<String>,
pub used_by: Vec<String>,
#[serde(skip_serializing, skip_deserializing)] #[serde(skip_serializing, skip_deserializing)]
pub status: Status, pub status: Status,
} }
@@ -23,14 +24,14 @@ pub struct UpdateResult {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[cfg_attr(test, derive(PartialEq, Default))] #[cfg_attr(test, derive(PartialEq))]
#[serde(untagged)] #[serde(untagged)]
pub enum UpdateInfo { pub enum UpdateInfo {
#[cfg_attr(test, default)] #[default]
None, None,
Version(VersionUpdateInfo), Version(VersionUpdateInfo),
Digest(DigestUpdateInfo), Digest(String), // Remote digest
} }
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]

View File

@@ -1,188 +1,63 @@
use std::{cmp::Ordering, fmt::Display}; use crate::{config::TagType, structs::standard_version::StandardVersion};
use once_cell::sync::Lazy; #[derive(Clone, Default, PartialEq, Debug)]
use regex::Regex; #[non_exhaustive]
pub enum Version {
use super::status::Status; #[default]
Unknown,
/// Semver-like version struct Semver(StandardVersion),
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Version {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
} }
impl Version { 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_standard(tag: &str) -> Result<Self, ()> {
pub fn from_tag(tag: &str) -> Option<(Self, String)> { match StandardVersion::from_tag(tag) {
/// 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. Ok(version) => Ok(Version::Semver(version)),
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| { Err(e) => Err(e),
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 to_status(&self, base: &Self) -> Status { pub fn format_string(&self) -> Option<String> {
match self.major.cmp(&base.major) { match self {
Ordering::Greater => Status::UpdateMajor, Self::Semver(v) => Some(v.format_str.clone()),
Ordering::Equal => match (self.minor, base.minor) { Self::Unknown => None,
(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)),
} }
} }
}
impl Ord for Version { pub fn from(tag: &str, tag_type: Option<&TagType>) -> Self {
fn cmp(&self, other: &Self) -> Ordering { match tag_type {
let major_ordering = self.major.cmp(&other.major); Some(t) => match t {
match major_ordering { TagType::Standard => Self::from_standard(tag).unwrap_or(Self::Unknown),
Ordering::Equal => match (self.minor, other.minor) { TagType::Extended => unimplemented!(),
(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,
}, },
_ => 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 { impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other)) 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
impl Display for Version { (Self::Semver(a), Self::Semver(b)) => a.partial_cmp(b),
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" ))));
} }
} }

View File

@@ -1,6 +1,6 @@
// Functions that return JSON data, used for generating output and API responses // 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}; 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. /// 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!({ json!({
"metrics": get_metrics(updates), "metrics": get_metrics(updates),
"images": updates.iter().map(|update| serde_json::to_value(update).unwrap()).collect::<Vec<Value>>(), "images": updates.iter().map(|update| serde_json::to_value(update).unwrap()).collect::<Vec<Value>>(),

View File

@@ -8,8 +8,12 @@ pub fn split(reference: &str) -> (String, String, String) {
0 => unreachable!(), 0 => unreachable!(),
1 => (DEFAULT_REGISTRY, reference.to_string()), 1 => (DEFAULT_REGISTRY, reference.to_string()),
_ => { _ => {
// Check if the image is from Docker Hub
if splits[0] == "docker.io" {
(DEFAULT_REGISTRY, splits[1..].join("/"))
// Check if we're looking at a domain // Check if we're looking at a domain
if splits[0] == "localhost" || splits[0].contains('.') || splits[0].contains(':') { } else if splits[0] == "localhost" || splits[0].contains('.') || splits[0].contains(':')
{
(splits[0], splits[1..].join("/")) (splits[0], splits[1..].join("/"))
} else { } else {
(DEFAULT_REGISTRY, reference.to_string()) (DEFAULT_REGISTRY, reference.to_string())
@@ -64,6 +68,7 @@ mod tests {
assert_eq!(split("localhost:1234/test" ), (String::from("localhost:1234" ), String::from("test" ), String::from("latest"))); assert_eq!(split("localhost:1234/test" ), (String::from("localhost:1234" ), String::from("test" ), String::from("latest")));
assert_eq!(split("test:1234/idk" ), (String::from("test:1234" ), String::from("idk" ), String::from("latest"))); assert_eq!(split("test:1234/idk" ), (String::from("test:1234" ), String::from("idk" ), String::from("latest")));
assert_eq!(split("alpine:3.7" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("3.7" ))); assert_eq!(split("alpine:3.7" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("3.7" )));
assert_eq!(split("docker.io/library/alpine" ), (String::from(DEFAULT_REGISTRY ), String::from("library/alpine" ), String::from("latest")));
assert_eq!(split("docker.example.com/examplerepo/alpine:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine" ), String::from("3.7" ))); assert_eq!(split("docker.example.com/examplerepo/alpine:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine" ), String::from("3.7" )));
assert_eq!(split("docker.example.com/examplerepo/alpine/test2:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2" ), String::from("3.7" ))); assert_eq!(split("docker.example.com/examplerepo/alpine/test2:3.7" ), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2" ), String::from("3.7" )));
assert_eq!(split("docker.example.com/examplerepo/alpine/test2/test3:3.7"), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2/test3"), String::from("3.7" ))); assert_eq!(split("docker.example.com/examplerepo/alpine/test2/test3:3.7"), (String::from("docker.example.com" ), String::from("examplerepo/alpine/test2/test3"), String::from("3.7" )));

1
web/.gitignore vendored
View File

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

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

@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.10", "@headlessui/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
@@ -23,7 +24,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",
"@types/node": "^22.5.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",

View File

@@ -4,11 +4,17 @@ import Statistic from "./components/Statistic";
import Image from "./components/Image"; import Image from "./components/Image";
import { LastChecked } from "./components/LastChecked"; import { LastChecked } from "./components/LastChecked";
import Loading from "./components/Loading"; import Loading from "./components/Loading";
import { Data } from "./types"; import { Filters as FiltersType } from "./types";
import { theme } from "./theme"; import { theme } from "./theme";
import RefreshButton from "./components/RefreshButton"; import RefreshButton from "./components/RefreshButton";
import Search from "./components/Search"; import Search from "./components/Search";
import { Server } from "./components/Server"; 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 = [ const SORT_ORDER = [
"monitored_images", "monitored_images",
@@ -22,10 +28,25 @@ const SORT_ORDER = [
]; ];
function App() { 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(""); 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 ( return (
<div <div
className={`flex min-h-screen justify-center bg-white dark:bg-${theme}-950`} className={`flex min-h-screen justify-center bg-white dark:bg-${theme}-950`}
@@ -59,14 +80,32 @@ function App() {
className={`border shadow-sm border-${theme}-200 dark:border-${theme}-900 my-8 rounded-md`} className={`border shadow-sm border-${theme}-200 dark:border-${theme}-900 my-8 rounded-md`}
> >
<div <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} /> <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> </div>
<div className="flex gap-2 px-6 text-black dark:text-white"> <div className="flex gap-2 px-6 text-black dark:text-white">
<Search onChange={setSearchQuery} /> <Search onChange={setSearchQuery} />
</div> </div>
{showFilters && (
<Filters
filters={filters}
setFilters={setFilters}
registries={[
...new Set(data.images.map((image) => image.parts.registry)),
]}
/>
)}
<ul> <ul>
{Object.entries( {Object.entries(
data.images.reduce<Record<string, typeof data.images>>( data.images.reduce<Record<string, typeof data.images>>(
@@ -83,6 +122,19 @@ function App() {
.map(([server, images]) => ( .map(([server, images]) => (
<Server name={server} key={server}> <Server name={server} key={server}>
{images {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)) .filter((image) => image.reference.includes(searchQuery))
.map((image) => ( .map((image) => (
<Image data={image} key={image.reference} /> <Image data={image} key={image.reference} />

View File

@@ -1,14 +1,13 @@
import { ArrowRight } from "lucide-react";
import { theme } from "../theme"; import { theme } from "../theme";
import { ReactNode } from "react";
import { cn } from "../utils";
export default function Badge({ from, to }: { from: string; to: string }) { export default function Badge({ children, className }: { children: ReactNode, className?: string }) {
return ( return (
<span <span
className={`inline-flex items-center rounded-full bg-${theme}-50 px-2 py-1 text-xs font-medium text-${theme}-700 ring-1 ring-inset ring-${theme}-700/10 dark:bg-${theme}-400/10 dark:text-${theme}-400 dark:ring-${theme}-400/30 break-keep`} 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)}
> >
{from} {children}
<ArrowRight className="size-3" />
{to}
</span> </span>
); );
} }

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,20 +5,23 @@ import {
DialogPanel, DialogPanel,
DialogTitle, DialogTitle,
} from "@headlessui/react"; } from "@headlessui/react";
import { WithTooltip } from "./Tooltip"; import { WithTooltip } from "./ui/Tooltip";
import type { Image } from "../types"; import type { Image } from "../types";
import { theme } from "../theme"; import { theme } from "../theme";
import { CodeBlock } from "./CodeBlock"; import { CodeBlock } from "./CodeBlock";
import { import {
ArrowRight,
Box, Box,
CircleArrowUp, CircleArrowUp,
CircleCheck, CircleCheck,
Container,
HelpCircle, HelpCircle,
Timer, Timer,
TriangleAlert, TriangleAlert,
X, X,
} from "lucide-react"; } from "lucide-react";
import Badge from "./Badge"; import Badge from "./Badge";
import { getDescription, truncateArray } from "../utils";
const clickable_registries = [ const clickable_registries = [
"registry-1.docker.io", "registry-1.docker.io",
@@ -39,7 +42,7 @@ export default function Image({ data }: { data: Image }) {
data.result.info?.type == "version" data.result.info?.type == "version"
? data.reference.split(":")[0] + ":" + data.result.info.new_tag ? data.reference.split(":")[0] + ":" + data.result.info.new_tag
: data.reference; : data.reference;
const info = getInfo(data)!; const info = getInfo(data);
let url: string | null = null; let url: string | null = null;
if (data.url) { if (data.url) {
url = data.url; url = data.url;
@@ -63,11 +66,16 @@ export default function Image({ data }: { data: Image }) {
<span className="font-mono">{data.reference}</span> <span className="font-mono">{data.reference}</span>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
{data.result.info?.type === "version" ? ( {data.result.info?.type === "version" ? (
<Badge <Badge className="hidden sm:inline-flex">
from={data.result.info.current_version} {data.result.info.current_version}
to={data.result.info.new_version} <ArrowRight className="size-3" />
/> {data.result.info.new_version}
</Badge>
) : null} ) : null}
<Badge className="hidden md:inline-flex">
<Container className="size-4 mr-1"/>
{data.used_by.length}
</Badge>
<WithTooltip <WithTooltip
text={info.description} text={info.description}
className={`size-6 shrink-0 ${info.color}`} className={`size-6 shrink-0 ${info.color}`}
@@ -139,6 +147,21 @@ export default function Image({ data }: { data: Image }) {
Checked in <b>{data.time}</b> ms Checked in <b>{data.time}</b> ms
</span> </span>
</div> </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 && ( {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"> <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" /> <TriangleAlert className="size-6 shrink-0 text-yellow-500" />
@@ -182,54 +205,49 @@ export default function Image({ data }: { data: Image }) {
); );
} }
function getInfo(data: Image): function getInfo(data: Image): {
| { color: string;
color: string; icon: typeof HelpCircle;
icon: typeof HelpCircle; description: string;
description: string; } {
} const description = getDescription(data);
| undefined { switch (description) {
switch (data.result.has_update) { case "Unknown":
case null:
return { return {
color: "text-gray-500", color: "text-gray-500",
icon: HelpCircle, icon: HelpCircle,
description: "Unknown", description,
}; };
case false: case "Up to date":
return { return {
color: "text-green-500", color: "text-green-500",
icon: CircleCheck, icon: CircleCheck,
description: "Up to date", description,
};
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,
}; };
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return {
color: "text-red-500",
icon: CircleArrowUp,
description: "Major update",
};
case "minor":
return {
color: "text-yellow-500",
icon: CircleArrowUp,
description: "Minor update",
};
case "patch":
return {
color: "text-blue-500",
icon: CircleArrowUp,
description: "Patch update",
};
}
} else if (data.result.info?.type === "digest") {
return {
color: "text-blue-500",
icon: CircleArrowUp,
description: "Update available",
};
}
} }
} }

View File

@@ -1,18 +1,8 @@
import { Data } from "../types";
import Logo from "./Logo"; import Logo from "./Logo";
import { theme } from "../theme"; import { theme } from "../theme";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) { export default function Loading() {
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);
}),
);
return ( return (
<div <div
className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`} className={`flex min-h-screen justify-center bg-${theme}-50 dark:bg-${theme}-950`}

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { WithTooltip } from "./Tooltip"; import { WithTooltip } from "./ui/Tooltip";
export default function RefreshButton() { export default function RefreshButton() {
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@@ -14,7 +14,7 @@ export default function RefreshButton() {
request.open( request.open(
"GET", "GET",
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
? "/api/v3/refresh" ? "./api/v3/refresh"
: `http://${window.location.hostname}:8000/api/v3/refresh`, : `http://${window.location.hostname}:8000/api/v3/refresh`,
); );
request.send(); request.send();

View File

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

@@ -28,6 +28,7 @@ export interface Image {
}; };
time: number; time: number;
server: string | null; server: string | null;
used_by: string[];
} }
interface VersionInfo { interface VersionInfo {
@@ -43,3 +44,16 @@ interface DigestInfo {
local_digests: string[]; local_digests: string[];
remote_digest: 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 { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { Image } from "./types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./src/App.tsx", "./src/components/*.tsx", "./index.liquid"], content: ["./src/App.tsx", "./src/components/**/*.tsx", "./index.liquid"],
theme: { theme: {
extend: {}, extend: {},
}, },
@@ -32,14 +32,18 @@ export default {
}, },
{ {
pattern: /text-(gray|neutral)-600/, pattern: /text-(gray|neutral)-600/,
variants: ["*", "dark", "hover", "placeholder"], variants: ["*", "dark", "hover", "placeholder", "data-[placeholder]"],
}, },
{ {
pattern: /text-(gray|neutral)-400/, pattern: /text-(gray|neutral)-400/,
variants: ["*:dark", "dark", "dark:hover", "placeholder:dark"], variants: ["*:dark", "dark", "dark:hover", "placeholder:dark", "data-[placeholder]:dark"],
}, },
{ {
pattern: /text-(gray|neutral)-700/, pattern: /text-(gray|neutral)-(500|700)/,
},
{
pattern: /text-(gray|neutral)-950/,
variants: ["dark:group-data-[state=checked]"]
}, },
{ {
pattern: /text-(gray|neutral)-800/, pattern: /text-(gray|neutral)-800/,
@@ -54,10 +58,10 @@ export default {
variants: ["dark"], 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"], variants: ["dark"],
}, },
{ {