mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-10 14:13:49 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bf187a4a | ||
|
|
2c120ffaff | ||
|
|
572ca8858a | ||
|
|
2c4f2a1e05 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -350,7 +350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cup"
|
||||
version = "2.2.1"
|
||||
version = "2.2.2"
|
||||
dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cup"
|
||||
version = "2.2.1"
|
||||
version = "2.2.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
6
build.sh
6
build.sh
@@ -1,7 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# This is kind of like a shim that makes sure the frontend is rebuilt when running a build. For example you can run `./build.sh cargo build --release`
|
||||
|
||||
# Remove old files
|
||||
rm -rf src/static
|
||||
|
||||
# Frontend
|
||||
cd web/
|
||||
|
||||
|
||||
BIN
docs/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png
Normal file
BIN
docs/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg
Normal file
BIN
docs/assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -11,6 +11,9 @@
|
||||
"usage": {
|
||||
"title": "Usage"
|
||||
},
|
||||
"community-resources": {
|
||||
"title": "Community Resources"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "Using the latest version"
|
||||
}
|
||||
|
||||
23
docs/pages/docs/community-resources/docker-compose.mdx
Normal file
23
docs/pages/docs/community-resources/docker-compose.mdx
Normal file
@@ -0,0 +1,23 @@
|
||||
# Docker Compose
|
||||
|
||||
Many users find it useful to run Cup with Docker Compose, as it enables them to have it constantly running in the background and easily control it. Cup's lightweight resource usae makes it ideal for this use case.
|
||||
|
||||
There have been requests for an official Docker Compose file, but I believe you should customize it to your needs.
|
||||
|
||||
Here is an example of what I would use (by [@ioverho](https://github.com/ioverho)):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cup:
|
||||
image: ghcr.io/sergi0g/cup:latest
|
||||
container_name: cup # Optional
|
||||
restart: unless-stopped
|
||||
command: -c /config/cup.json serve
|
||||
ports:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./cup.json:/config/cup.json
|
||||
```
|
||||
|
||||
This can be customized further of course, if you choose to use a different port, another config location, or would like to change something else. Have fun!
|
||||
75
docs/pages/docs/community-resources/homepage-widget.mdx
Normal file
75
docs/pages/docs/community-resources/homepage-widget.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Image from 'next/image';
|
||||
import widget1 from '../../../assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png'
|
||||
import widget2 from '../../../assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg'
|
||||
|
||||
# Homepage Widget
|
||||
|
||||
Some users have asked for a homepage widget.
|
||||
|
||||
## Docker Compose with the widget configured via labels:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cup:
|
||||
image: ghcr.io/sergi0g/cup
|
||||
container_name: cup
|
||||
command: -c /config/cup.json serve -p 8000
|
||||
volumes:
|
||||
- ./config/cup.json:/config/cup.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 8000:8000
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.group: Network
|
||||
homepage.name: Cup
|
||||
homepage.icon: /icons/cup-with-straw.png
|
||||
homepage.href: http://myserver:8000
|
||||
homepage.ping: http://myserver:8000
|
||||
homepage.description: Checks for container updates
|
||||
homepage.widget.type: customapi
|
||||
homepage.widget.url: http://myserver:8000/json
|
||||
homepage.widget.mappings[0].label: Monitoring
|
||||
homepage.widget.mappings[0].field.metrics: monitored_images
|
||||
homepage.widget.mappings[0].format: number
|
||||
homepage.widget.mappings[1].label: Up to date
|
||||
homepage.widget.mappings[1].field.metrics: up_to_date
|
||||
homepage.widget.mappings[1].format: number
|
||||
homepage.widget.mappings[2].label: Updates
|
||||
homepage.widget.mappings[2].field.metrics: update_available
|
||||
homepage.widget.mappings[2].format: number
|
||||
```
|
||||
Preview:
|
||||
<Image src={widget1}/>
|
||||
|
||||
Credit: [@agrmohit](https://github.com/agrmohit)
|
||||
|
||||
## Widget in Homepage's config file format:
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://<SERVER_IP>:9000/json
|
||||
refreshInterval: 10000
|
||||
method: GET
|
||||
mappings:
|
||||
- field:
|
||||
metrics: monitored_images
|
||||
label: Monitored images
|
||||
format: number
|
||||
- field:
|
||||
metrics: up_to_date
|
||||
label: Up to date
|
||||
format: number
|
||||
- field:
|
||||
metrics: update_available
|
||||
label: Available updates
|
||||
format: number
|
||||
- field:
|
||||
metrics: unknown
|
||||
label: Unknown
|
||||
format: number
|
||||
```
|
||||
Preview:
|
||||
<Image src={widget2}/>
|
||||
Credit: [@remussamoila](https://github.com/remussamoila)
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
const { asPath } = useRouter()
|
||||
const { frontMatter } = useConfig()
|
||||
const url =
|
||||
'https://sergi0g.github.io/cup' +
|
||||
'https://sergi0g.github.io/cup/docs/' +
|
||||
(`/${asPath}`);
|
||||
|
||||
return (
|
||||
@@ -36,9 +36,9 @@ export default {
|
||||
<h1 className="font-bold ml-2">Cup</h1>
|
||||
</div>
|
||||
),
|
||||
logoLink: "sergi0g.github.io/cup",
|
||||
logoLink: "https://sergi0g.github.io/cup/docs/",
|
||||
project: {
|
||||
link: "https://github.com/sergi0g/cup",
|
||||
link: "https://github.com/sergi0g/cup/",
|
||||
},
|
||||
navbar: {
|
||||
extraContent: <ThemeSwitch lite className="[&_span]:hidden" />,
|
||||
|
||||
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -11,10 +11,14 @@
|
||||
"fmt": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
import { MouseEvent, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Logo from "./components/Logo";
|
||||
import Statistic from "./components/Statistic";
|
||||
import Image from "./components/Image";
|
||||
import { LastChecked } from "./components/LastChecked";
|
||||
import Loading from "./components/Loading";
|
||||
import { Data } from "./types";
|
||||
import { theme } from "./theme";
|
||||
import RefreshButton from "./components/RefreshButton";
|
||||
import Search from "./components/Search";
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const theme = "neutral"; // Stupid, I know but I want both the dev and prod to work easily.
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
if (!data) return <Loading onLoad={setData} />;
|
||||
const refresh = (event: MouseEvent) => {
|
||||
const btn = event.currentTarget as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
|
||||
let request = new XMLHttpRequest();
|
||||
request.onload = () => {
|
||||
if (request.status === 200) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
request.open(
|
||||
"GET",
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/refresh"
|
||||
: `http://${window.location.hostname}:8000/refresh`
|
||||
);
|
||||
request.send();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
||||
@@ -56,29 +41,13 @@ function App() {
|
||||
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
|
||||
>
|
||||
<LastChecked datetime={data.last_updated} />
|
||||
<button className="group" onClick={refresh}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="group-disabled:animate-spin"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
|
||||
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
<RefreshButton />
|
||||
</div>
|
||||
<Search onChange={setSearchQuery}/>
|
||||
<ul
|
||||
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
|
||||
>
|
||||
{Object.entries(data.images).map(([name, status]) => (
|
||||
{Object.entries(data.images).filter(([name]) => name.includes(searchQuery)).map(([name, status]) => (
|
||||
<Image name={name} status={status} key={name} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconCube,
|
||||
IconHelpCircleFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { WithTooltip } from "./Tooltip";
|
||||
|
||||
export default function Image({
|
||||
name,
|
||||
@@ -17,13 +18,28 @@ export default function Image({
|
||||
<IconCube className="size-6 shrink-0" />
|
||||
{name}
|
||||
{status == false && (
|
||||
<IconCircleCheckFilled className="text-green-500 ml-auto size-6 shrink-0" />
|
||||
<WithTooltip
|
||||
text="Up to date"
|
||||
className="text-green-500 ml-auto size-6 shrink-0"
|
||||
>
|
||||
<IconCircleCheckFilled />
|
||||
</WithTooltip>
|
||||
)}
|
||||
{status == true && (
|
||||
<IconCircleArrowUpFilled className="text-blue-500 ml-auto size-6 shrink-0" />
|
||||
<WithTooltip
|
||||
text="Update available"
|
||||
className="text-blue-500 ml-auto size-6 shrink-0"
|
||||
>
|
||||
<IconCircleArrowUpFilled />
|
||||
</WithTooltip>
|
||||
)}
|
||||
{status == null && (
|
||||
<IconHelpCircleFilled className="text-gray-500 ml-auto size-6 shrink-0" />
|
||||
<WithTooltip
|
||||
text="Unknown"
|
||||
className="text-gray-500 ml-auto size-6 shrink-0"
|
||||
>
|
||||
<IconHelpCircleFilled />
|
||||
</WithTooltip>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
import { Data } from "../types";
|
||||
import Logo from "./Logo";
|
||||
import { theme } from "../theme";
|
||||
|
||||
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
|
||||
const theme = "neutral";
|
||||
fetch(
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/json"
|
||||
: `http://${window.location.hostname}:8000/json`,
|
||||
).then((response) => response.json().then((data) => onLoad(data)));
|
||||
).then((response) => response.json().then((data) => {onLoad(data as Data)}));
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
||||
|
||||
45
web/src/components/RefreshButton.tsx
Normal file
45
web/src/components/RefreshButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MouseEvent } from "react";
|
||||
import { WithTooltip } from "./Tooltip";
|
||||
|
||||
export default function RefreshButton() {
|
||||
const refresh = (event: MouseEvent) => {
|
||||
const btn = event.currentTarget as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.onload = () => {
|
||||
if (request.status === 200) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
request.open(
|
||||
"GET",
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/refresh"
|
||||
: `http://${window.location.hostname}:8000/refresh`,
|
||||
);
|
||||
request.send();
|
||||
};
|
||||
return (
|
||||
<WithTooltip text="Reload">
|
||||
<button className="group" onClick={refresh}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="group-disabled:animate-spin"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
|
||||
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
);
|
||||
}
|
||||
51
web/src/components/Search.tsx
Normal file
51
web/src/components/Search.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import { theme } from "../theme";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
|
||||
export default function Search({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showClear, setShowClear] = useState(false);
|
||||
const handleChange = (event: ChangeEvent) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
setSearchQuery(value);
|
||||
onChange(value);
|
||||
if (value !== "") {
|
||||
setShowClear(true);
|
||||
} else setShowClear(false);
|
||||
};
|
||||
const handleClear = () => {
|
||||
setShowClear(false);
|
||||
setSearchQuery("");
|
||||
onChange("");
|
||||
};
|
||||
return (
|
||||
<div className={`w-full px-6 text-${theme}-500`}>
|
||||
<div
|
||||
className={`flex items-center w-full rounded-md border border-${theme}-200 dark:border-${theme}-700 px-2 gap-1 bg-${theme}-800 flex-nowrap peer`}
|
||||
>
|
||||
<IconSearch className="size-5" />
|
||||
<div className="w-full">
|
||||
<input
|
||||
className={`w-full h-10 text-sm text-${theme}-400 focus:outline-none peer bg-transparent placeholder:text-${theme}-500`}
|
||||
placeholder="Search"
|
||||
onChange={handleChange}
|
||||
value={searchQuery}
|
||||
></input>
|
||||
</div>
|
||||
{showClear && (
|
||||
<button onClick={handleClear} className={`hover:text-${theme}-400`}>
|
||||
<IconX className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="relative -translate-y-[8px] h-[8px] border-b-blue-600 border-b-2 w-0 peer-has-[:focus]:w-full transition-all duration-200 rounded-md left-1/2 -translate-x-1/2"
|
||||
style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconEyeFilled,
|
||||
IconHelpCircleFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { theme } from "../theme";
|
||||
|
||||
export default function Statistic({
|
||||
name,
|
||||
@@ -12,7 +13,6 @@ export default function Statistic({
|
||||
name: string;
|
||||
value: number;
|
||||
}) {
|
||||
const theme = "neutral";
|
||||
name = name.replaceAll("_", " ");
|
||||
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
|
||||
return (
|
||||
|
||||
46
web/src/components/Tooltip.tsx
Normal file
46
web/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "../utils";
|
||||
import { forwardRef, ReactNode } from "react";
|
||||
import { theme } from "../theme";
|
||||
|
||||
const TooltipContent = forwardRef<
|
||||
React.ElementRef<typeof Content>,
|
||||
React.ComponentPropsWithoutRef<typeof Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
`z-50 overflow-hidden rounded-md border border-${theme}-200 bg-white px-3 py-1.5 text-sm text-${theme}-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-${theme}-800 dark:bg-${theme}-950 dark:text-${theme}-50`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
TooltipContent.displayName = Content.displayName;
|
||||
|
||||
const WithTooltip = ({
|
||||
children,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
text: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Provider>
|
||||
<Root>
|
||||
<Trigger className={className} asChild>
|
||||
{children}
|
||||
</Trigger>
|
||||
<TooltipContent>
|
||||
<p className="text-black dark:text-white">{text}</p>
|
||||
</TooltipContent>
|
||||
</Root>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { WithTooltip };
|
||||
1
web/src/theme.ts
Normal file
1
web/src/theme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const theme = "neutral"; // Will be modified by server at runtime
|
||||
@@ -1,12 +1,10 @@
|
||||
export type Data = {
|
||||
export interface Data {
|
||||
metrics: {
|
||||
monitored_images: number;
|
||||
up_to_date: number;
|
||||
update_available: number;
|
||||
unknown: number;
|
||||
};
|
||||
images: {
|
||||
[key: string]: boolean | null;
|
||||
};
|
||||
images: Record<string, boolean | null>;
|
||||
last_updated: string;
|
||||
};
|
||||
|
||||
6
web/src/utils.ts
Normal file
6
web/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
safelist: [
|
||||
// Generate minimum extra CSS
|
||||
{
|
||||
@@ -24,14 +24,22 @@ export default {
|
||||
},
|
||||
{
|
||||
pattern: /text-(gray|neutral)-400/,
|
||||
variants: ["hover"]
|
||||
},
|
||||
{
|
||||
pattern: /text-(gray|neutral)-500/,
|
||||
variants: ["dark"],
|
||||
variants: ["dark", "placeholder"],
|
||||
},
|
||||
{
|
||||
pattern: /divide-(gray|neutral)-800/,
|
||||
variants: ["dark"],
|
||||
},
|
||||
{
|
||||
pattern: /border-(gray|neutral)-200/,
|
||||
},
|
||||
{
|
||||
pattern: /border-(gray|neutral)-700/,
|
||||
variants: ["dark"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import react from "@vitejs/plugin-react-swc";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: { // https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
|
||||
rollupOptions: {
|
||||
// https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
|
||||
output: {
|
||||
entryFileNames: `assets/[name].js`,
|
||||
chunkFileNames: `assets/[name].js`,
|
||||
|
||||
Reference in New Issue
Block a user