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

4 Commits

Author SHA1 Message Date
Sergio
38bf187a4a Update docs with community requested changes and bump version
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-09-09 11:42:04 +03:00
Sergio
2c120ffaff Added search 2024-09-09 11:18:28 +03:00
Sergio
572ca8858a Added tooltips, centralized theme declaration, fixed some eslint errors
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-09-07 18:57:57 +03:00
Sergio
2c4f2a1e05 Fix broken links in docs 2024-09-07 18:07:32 +03:00
23 changed files with 310 additions and 58 deletions

2
Cargo.lock generated
View File

@@ -350,7 +350,7 @@ dependencies = [
[[package]]
name = "cup"
version = "2.2.1"
version = "2.2.2"
dependencies = [
"bollard",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "cup"
version = "2.2.1"
version = "2.2.2"
edition = "2021"
[dependencies]

View File

@@ -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/

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -11,6 +11,9 @@
"usage": {
"title": "Usage"
},
"community-resources": {
"title": "Community Resources"
},
"nightly": {
"title": "Using the latest version"
}

View 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!

View 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)

View File

@@ -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" />,

Binary file not shown.

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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`}

View 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>
);
}

View 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>
);
}

View File

@@ -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 (

View 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
View File

@@ -0,0 +1 @@
export const theme = "neutral"; // Will be modified by server at runtime

View File

@@ -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
View 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));
}

View File

@@ -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"],
},
],
};

View File

@@ -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`,