first public source commit

pull/3/head
Ben Phelps 2 years ago
parent 1a4fbb9d42
commit 3914fee775

@ -0,0 +1,24 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
README.md
config/

@ -0,0 +1,13 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120

3
.gitignore vendored

@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# homepage
/config

@ -0,0 +1,35 @@
# Install dependencies only when needed
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN yarn global add pnpm
RUN pnpm install
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Since we run in a local environment, we need to accept self signed certificates
ENV NODE_TLS_REJECT_UNAUTHORIZED 0
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

@ -1,34 +1,21 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
Install NPM packages, this project uses [pnpm](https://pnpm.io/) (and so should you!):
```bash
npm run dev
# or
yarn dev
pnpm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
Start the development server:
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
pnpm dev
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
Open [http://localhost:3000](http://localhost:3000) to start.
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Configuration
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
Configuration is done in the /config directory using .yaml files. Refer to each config for
the specific configuration options.

@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": "./src/",
}
}

@ -1,7 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
swcMinify: true,
}
images: {
domains: ["cdn.jsdelivr.net"],
},
};
module.exports = nextConfig
module.exports = nextConfig;

@ -9,12 +9,25 @@
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.6.6",
"@tailwindcss/forms": "^0.5.2",
"dockerode": "^3.3.3",
"js-yaml": "^4.1.0",
"memory-cache": "^0.2.0",
"next": "12.2.5",
"node-os-utils": "^1.3.7",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"react-icons": "^4.4.0",
"sharp": "^0.30.7",
"swr": "^1.3.0"
},
"devDependencies": {
"autoprefixer": "^10.4.8",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5"
"eslint-config-next": "12.2.5",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8",
"typescript": "^4.7.4"
}
}

@ -1,7 +0,0 @@
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp

@ -1,5 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
export default function handler(req, res) {
res.status(200).json({ name: 'John Doe' })
}

@ -1,69 +0,0 @@
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.js</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h2>Documentation &rarr;</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h2>Learn &rarr;</h2>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/canary/examples"
className={styles.card}
>
<h2>Examples &rarr;</h2>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h2>Deploy &rarr;</h2>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
</div>
)
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1,15 @@
import List from "components/bookmarks/list";
export default function BookmarksGroup({ group }) {
return (
<div
key={group.name}
className="basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4 flex-1 p-1"
>
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">
{group.name}
</h2>
<List bookmarks={group.bookmarks} />
</div>
);
}

@ -0,0 +1,23 @@
export default function Item({ bookmark }) {
const { hostname } = new URL(bookmark.href);
return (
<li
onClick={() => {
window.open(bookmark.href, "_blank").focus();
}}
key={bookmark.name}
className="mb-3 cursor-pointer flex rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<div className="flex-shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md">
{bookmark.abbr}
</div>
<div className="flex-1 flex items-center justify-between rounded-r-md ">
<div className="flex-1 grow pl-3 py-2 text-xs">{bookmark.name}</div>
<div className="px-2 py-2 truncate text-theme-500 dark:text-theme-400 opacity-50 text-xs">
{hostname}
</div>
</div>
</li>
);
}

@ -0,0 +1,11 @@
import Item from "components/bookmarks/item";
export default function List({ bookmarks }) {
return (
<ul role="list" className="mt-3 flex flex-col">
{bookmarks.map((bookmark) => (
<Item key={bookmark.name} bookmark={bookmark} />
))}
</ul>
);
}

@ -0,0 +1,20 @@
export default function Greeting() {
const name = process.env.NEXT_PUBLIC_DISPLAY_NAME;
const hour = new Date().getHours();
let day = "day";
if (hour < 12) {
day = "morning";
} else if (hour < 17) {
day = "afternoon";
} else {
day = "evening";
}
return (
<div className="self-end grow text-2xl font-thin text-theme-800 dark:text-theme-200">
Good {day}
</div>
);
}

@ -0,0 +1,68 @@
import { Fragment, useRef, useState, Children } from "react";
import { Dialog, Transition } from "@headlessui/react";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
const Modal = ({ Toggle, Content }) => {
const [open, setOpen] = useState(false);
const cancelButtonRef = useRef(null);
return (
<>
<Toggle open={open} setOpen={setOpen} />
<Transition.Root show={open} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={setOpen}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-theme-900/90 transition-opacity" />
</Transition.Child>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-full">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative rounded-lg shadow-xl transform transition-all my-8 max-w-lg w-full">
<Content open={open} setOpen={setOpen} />
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
const ModalToggle = ({ open, setOpen, children }) => (
<div onClick={() => setOpen(!open)}>{children}</div>
);
const ModalContent = ({ open, setOpen, children }) => (
<div className="body">{children}</div>
);
Modal.Toggle = ModalToggle;
Modal.Content = ModalContent;
export default Modal;

@ -0,0 +1,15 @@
import List from "components/services/list";
export default function ServicesGroup({ services }) {
return (
<div
key={services.name}
className="basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4 flex-1 p-1"
>
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">
{services.name}
</h2>
<List services={services.services} />
</div>
);
}

@ -0,0 +1,58 @@
import Image from "next/image";
import { useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import StatsList from "./stats/list";
import Status from "./status";
import Widget from "./widget";
export default function Item({ service }) {
const [statsOpen, setStatsOpen] = useState(false);
return (
<li key={service.name} className="">
<Disclosure>
<div className="transition-all h-15 overflow-hidden mb-3 cursor-pointer p-1 rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10">
<div className="flex">
<div
onClick={() => {
window.open(service.href, "_blank").focus();
}}
className="flex-shrink-0 flex items-center justify-center w-12 "
>
<Image
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${service.icon}`}
width={32}
height={32}
alt="logo"
/>
</div>
<div
onClick={() => {
window.open(service.href, "_blank").focus();
}}
className="flex-1 flex items-center justify-between rounded-r-md "
>
<div className="flex-1 px-2 py-2 text-sm">
{service.name}
<p className="text-theme-500 dark:text-theme-400 text-xs font-extralight">{service.description}</p>
</div>
</div>
{service.container && (
<Disclosure.Button as="div" className="flex-shrink-0 flex items-center justify-center w-12 ">
<Status service={service} />
</Disclosure.Button>
)}
</div>
<Disclosure.Panel>
<div className="w-full">
<StatsList service={service} />
</div>
</Disclosure.Panel>
{service.widget && <Widget service={service} />}
</div>
</Disclosure>
</li>
);
}

@ -0,0 +1,11 @@
import Item from "components/services/item";
export default function List({ services }) {
return (
<ul role="list" className="mt-3 flex flex-col">
{services.map((service) => (
<Item key={service.name} service={service} />
))}
</ul>
);
}

@ -0,0 +1,70 @@
import useSWR from "swr";
import { calculateCPUPercent, formatBytes } from "utils/stats-helpers";
import Stat from "./stat";
export default function Stats({ service }) {
// fast
const { data: statusData, error: statusError } = useSWR(
`/api/docker/status/${service.container}/${service.server || ""}`,
{
refreshInterval: 1500,
}
);
// takes a full second to collect stats
const { data: statsData, error: statsError } = useSWR(
`/api/docker/stats/${service.container}/${service.server || ""}`,
{
refreshInterval: 1500,
}
);
// handle errors first
if (statsError || statusError) {
return (
<div className="flex flex-row w-full">
<Stat label="STATUS" value="Error Fetching Data" />
</div>
);
}
// handle the case where we get a docker error
if (statusData.status !== "running") {
return (
<div className="flex flex-row w-full">
<Stat label="STATUS" value="Error Fetching Data" />
</div>
);
}
// handle the case where the container is offline
if (statusData.status !== "running") {
return (
<div className="flex flex-row w-full">
<Stat label="STATUS" value="Offline" />
</div>
);
}
// handle the case where we don't have anything yet
if (!statsData || !statusData) {
return (
<div className="flex flex-row w-full">
<Stat label="CPU" value="-" />
<Stat label="MEM" value="-" />
<Stat label="RX" value="-" />
<Stat label="TX" value="-" />
</div>
);
}
// we have stats and the container is running
return (
<div className="flex flex-row w-full">
<Stat label="CPU" value={calculateCPUPercent(statsData.stats) + "%"} />
<Stat label="MEM" value={formatBytes(statsData.stats.memory_stats.usage, 0)} />
<Stat label="RX" value={formatBytes(statsData.stats.networks.eth0.rx_bytes, 0)} />
<Stat label="TX" value={formatBytes(statsData.stats.networks.eth0.tx_bytes, 0)} />
</div>
);
}

@ -0,0 +1,8 @@
export default function Stat({ value, label }) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{value}</div>
<div className="font-bold text-xs">{label}</div>
</div>
);
}

@ -0,0 +1,29 @@
import useSWR from "swr";
export default function Status({ service }) {
const { data, error } = useSWR(
`/api/docker/status/${service.container}/${service.server || ""}`
);
if (error) {
return (
<div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />
);
}
if (data && data.status === "running") {
return (
<div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />
);
}
if (data && data.status === "not found") {
return (
<>
<div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45"></div>
</>
);
}
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />;
}

@ -0,0 +1,27 @@
import Sonarr from "./widgets/sonarr";
import Radarr from "./widgets/radarr";
import Ombi from "./widgets/ombi";
import Portainer from "./widgets/portainer";
const widgetMappings = {
sonarr: Sonarr,
radarr: Radarr,
ombi: Ombi,
portainer: Portainer,
};
export default function Widget({ service }) {
const ServiceWidget = widgetMappings[service.widget.type];
if (ServiceWidget) {
return <ServiceWidget service={service} />;
}
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">
Missing Widget Type: <strong>{service.widget.type}</strong>
</div>
</div>
);
}

@ -0,0 +1,71 @@
import useSWR from "swr";
export default function Ombi({ service }) {
const config = service.widget;
function buildApiUrl(endpoint) {
const { url } = config;
return `${url}/api/v1/${endpoint}`;
}
const fetcher = (url) => {
return fetch(url, {
method: "GET",
withCredentials: true,
credentials: "include",
headers: {
ApiKey: `${config.key}`,
"Content-Type": "application/json",
},
}).then((res) => res.json());
};
const { data: statsData, error: statsError } = useSWR(
buildApiUrl(`Request/count`),
fetcher
);
if (statsError) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Ombi API Error</div>
</div>
);
}
if (!statsData) {
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">COMPLETED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">QUEUED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">TOTAL</div>
</div>
</div>
);
}
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{statsData.pending}</div>
<div className="font-bold text-xs">PENDING</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{statsData.approved}</div>
<div className="font-bold text-xs">APPROVED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{statsData.available}</div>
<div className="font-bold text-xs">AVAILABLE</div>
</div>
</div>
);
}

@ -0,0 +1,81 @@
import useSWR from "swr";
export default function Portainer({ service }) {
const config = service.widget;
function buildApiUrl(endpoint) {
const { url, env } = config;
const reqUrl = new URL(`/api/endpoints/${env}/${endpoint}`, url);
return `/api/proxy?url=${encodeURIComponent(reqUrl)}`;
}
const fetcher = (url) => {
return fetch(url, {
method: "GET",
withCredentials: true,
credentials: "include",
headers: {
"X-API-Key": `${config.key}`,
"Content-Type": "application/json",
},
}).then((res) => res.json());
};
const { data: containersData, error: containersError } = useSWR(buildApiUrl(`docker/containers/json`), fetcher);
if (containersError) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Portainer API Error</div>
</div>
);
}
if (!containersData) {
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">RUNNING</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">STOPPED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">TOTAL</div>
</div>
</div>
);
}
if (containersData.error) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Portainer API Error</div>
</div>
);
}
const running = containersData.filter((c) => c.State === "running").length;
const stopped = containersData.filter((c) => c.State === "exited").length;
const total = containersData.length;
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{running}</div>
<div className="font-bold text-xs">RUNNING</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{stopped}</div>
<div className="font-bold text-xs">STOPPED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{total}</div>
<div className="font-bold text-xs">TOTAL</div>
</div>
</div>
);
}

@ -0,0 +1,63 @@
import useSWR from "swr";
export default function Radarr({ service }) {
const config = service.widget;
function buildApiUrl(endpoint) {
const { url, key } = config;
return `${url}/api/v3/${endpoint}?apikey=${key}`;
}
const { data: moviesData, error: moviesError } = useSWR(buildApiUrl("movie"));
const { data: queuedData, error: queuedError } = useSWR(
buildApiUrl("queue/status")
);
if (moviesError || queuedError) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Radarr API Error</div>
</div>
);
}
if (!moviesData || !queuedData) {
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">WANTED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">QUEUED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">-</div>
<div className="font-bold text-xs">MOVIES</div>
</div>
</div>
);
}
const wanted = moviesData.filter((movie) => movie.isAvailable === false);
const have = moviesData.filter((movie) => movie.isAvailable === true);
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{wanted.length}</div>
<div className="font-bold text-xs">WANTED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{queuedData.totalCount}</div>
<div className="font-bold text-xs">QUEUED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{moviesData.length}</div>
<div className="font-bold text-xs">MOVIES</div>
</div>
</div>
);
}

@ -0,0 +1,53 @@
import useSWR from "swr";
export default function Sonarr({ service }) {
const config = service.widget;
function buildApiUrl(endpoint) {
const { url, key } = config;
return `${url}/api/v3/${endpoint}?apikey=${key}`;
}
const { data: wantedData, error: wantedError } = useSWR(
buildApiUrl("wanted/missing")
);
const { data: queuedData, error: queuedError } = useSWR(buildApiUrl("queue"));
const { data: seriesData, error: seriesError } = useSWR(
buildApiUrl("series")
);
if (wantedError || queuedError || seriesError) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Sonarr API Error</div>
</div>
);
}
if (!wantedData || !queuedData || !seriesData) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">Loading</div>
</div>
);
}
return (
<div className="flex flex-row w-full">
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{wantedData.totalRecords}</div>
<div className="font-bold text-xs">WANTED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{queuedData.totalRecords}</div>
<div className="font-bold text-xs">QUEUED</div>
</div>
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{seriesData.length}</div>
<div className="font-bold text-xs">SERIES</div>
</div>
</div>
);
}

@ -0,0 +1,35 @@
import { useContext } from "react";
import {
MdDarkMode,
MdLightMode,
MdToggleOff,
MdToggleOn,
} from "react-icons/md";
import { ThemeContext } from "utils/theme-context";
export default function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
if (!theme) {
return null;
}
return (
<div className="rounded-full flex self-end">
<MdLightMode className="text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5" />
{theme === "dark" ? (
<MdToggleOn
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer"
/>
) : (
<MdToggleOff
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer"
/>
)}
<MdDarkMode className="text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5" />
</div>
);
}

@ -0,0 +1,21 @@
import Weather from "components/widgets/weather/weather";
import Resources from "components/widgets/resources/resources";
const widgetMappings = {
weather: Weather,
resources: Resources,
};
export default function Widget({ widget }) {
const ServiceWidget = widgetMappings[widget.type];
if (ServiceWidget) {
return <ServiceWidget options={widget.options} />;
}
return (
<div className="flex-none flex flex-row items-center justify-center">
Missing <strong>{widget.type}</strong>
</div>
);
}

@ -0,0 +1,114 @@
import useSWR from "swr";
import { FiHardDrive, FiCpu } from "react-icons/fi";
import { FaMemory } from "react-icons/fa";
export default function Resources({ options }) {
const { data, error } = useSWR(
`/api/widgets/resources?disk=${options.disk}`,
{
refreshInterval: 1500,
}
);
if (error) {
return <div>failed to load</div>;
}
if (!data) {
return (
<>
{options.disk && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
- GB free
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
- GB used
</span>
</div>
</div>
)}
{options.cpu && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
- Usage
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
- Load
</span>
</div>
</div>
)}
{options.memory && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
- GB Used
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
- GB Free
</span>
</div>
</div>
)}
</>
);
}
if (data.error) {
return <div className="flex flex-col items-center justify-center"></div>;
}
return (
<>
{options.disk && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round(data.drive.freeGb)} GB free
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round(data.drive.totalGb)} GB used
</span>
</div>
</div>
)}
{options.cpu && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round(data.cpu.usage)}% Usage
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round(data.cpu.load * 100) / 100} Load
</span>
</div>
</div>
)}
{options.memory && (
<div className="flex-none flex flex-row items-center justify-center mr-5">
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round((data.memory.usedMemMb / 1024) * 100) / 100} GB Used
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{Math.round((data.memory.freeMemMb / 1024) * 100) / 100} GB Free
</span>
</div>
</div>
)}
</>
);
}

@ -0,0 +1,9 @@
import mapIcon from "utils/condition-map";
export default function Icon({ condition, timeOfDay }) {
const Icon = mapIcon(condition, timeOfDay);
return (
<Icon className="mt-2 w-10 h-10 text-theme-800 dark:text-theme-200"></Icon>
);
}

@ -0,0 +1,41 @@
import useSWR from "swr";
import Icon from "./icon";
export default function Weather({ options }) {
const { data, error } = useSWR(
`/api/widgets/weather?lat=${options.latitude}&lon=${options.longitude}&apiKey=${options.apiKey}&duration=${options.cache}`,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
if (error) {
return <div>failed to load</div>;
}
if (!data) {
return <div className="flex flex-col items-center justify-center"></div>;
}
if (data.error) {
return <div className="flex flex-col items-center justify-center"></div>;
}
return (
<div className="order-last grow flex-none flex flex-row items-center justify-end">
<Icon
condition={data.current.condition.code}
timeOfDay={data.current.is_day ? "day" : "night"}
/>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">
{Math.round(data.current.temp_f)}&deg;
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{data.current.condition.text}
</span>
</div>
</div>
);
}

@ -0,0 +1,18 @@
import { SWRConfig } from "swr";
import "styles/globals.css";
import "styles/weather-icons.css";
function MyApp({ Component, pageProps }) {
return (
<SWRConfig
value={{
fetcher: (resource, init) =>
fetch(resource, init).then((res) => res.json()),
}}
>
<Component {...pageProps} />
</SWRConfig>
);
}
export default MyApp;

@ -0,0 +1,24 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html>
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="true"
/>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&display=swap"
rel="stylesheet"
/>
</Head>
<body className="w-full h-full bg-theme-50 dark:bg-theme-800 transition duration-150 ease-in-out">
<Main />
<NextScript />
</body>
</Html>
);
}

@ -0,0 +1,27 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function handler(req, res) {
checkAndCopyConfig("bookmarks.yaml");
const bookmarksYaml = path.join(process.cwd(), "config", "bookmarks.yaml");
const fileContents = await fs.readFile(bookmarksYaml, "utf8");
const bookmarks = yaml.load(fileContents);
// map easy to write YAML objects into easy to consume JS arrays
const bookmarksArray = bookmarks.map((group) => {
return {
name: Object.keys(group)[0],
bookmarks: group[Object.keys(group)[0]].map((entries) => {
return {
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]][0],
};
}),
};
});
res.send(bookmarksArray);
}

@ -0,0 +1,49 @@
import Docker from "dockerode";
import getDockerArguments from "utils/docker";
export default async function handler(req, res) {
const { service } = req.query;
const [containerName, containerServer] = service;
if (!containerName && !containerServer) {
res.status(400).send({
error: "docker query parameters are required",
});
return;
}
try {
const docker = new Docker(await getDockerArguments(containerServer));
const containers = await docker.listContainers();
// bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array
if (!Array.isArray(containers)) {
return res.status(500).send({
error: "query failed",
});
}
const containerNames = containers.map((container) => {
return container.Names[0].replace(/^\//, "");
});
const containerExists = containerNames.includes(containerName);
if (!containerExists) {
return res.status(404).send({
error: "not found",
});
}
const container = docker.getContainer(containerName);
const stats = await container.stats({ stream: false });
return res.status(200).json({
stats: stats,
});
} catch {
return res.status(500).send({
error: "unknown error",
});
}
}

@ -0,0 +1,48 @@
import Docker from "dockerode";
import getDockerArguments from "utils/docker";
export default async function handler(req, res) {
const { service } = req.query;
const [containerName, containerServer] = service;
if (!containerName && !containerServer) {
return res.status(400).send({
error: "docker query parameters are required",
});
}
try {
const docker = new Docker(await getDockerArguments(containerServer));
const containers = await docker.listContainers();
// bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array
if (!Array.isArray(containers)) {
return res.status(500).send({
error: "query failed",
});
}
const containerNames = containers.map((container) => {
return container.Names[0].replace(/^\//, "");
});
const containerExists = containerNames.includes(containerName);
if (!containerExists) {
return res.status(404).send({
error: "not found",
});
}
const container = docker.getContainer(containerName);
const info = await container.inspect();
return res.status(200).json({
status: info.State.Status,
});
} catch {
return res.status(500).send({
error: "unknown error",
});
}
}

@ -0,0 +1,29 @@
function pick(object, keys) {
return;
}
export default async function handler(req, res) {
const headers = ["X-API-Key", "Content-Type", "Authorization"].reduce((obj, key) => {
if (req.headers && req.headers.hasOwnProperty(key.toLowerCase())) {
obj[key] = req.headers[key.toLowerCase()];
}
return obj;
}, {});
try {
const result = await fetch(req.query.url, {
strictSSL: false,
rejectUnhauthorized: false,
method: req.method,
headers: headers,
body: req.method == "GET" || req.method == "HEAD" ? null : req.body,
}).then((res) => res);
const forward = await result.text();
return res.status(result.status).send(forward);
} catch {
return res.status(500).send({
error: "query failed",
});
}
}

@ -0,0 +1,27 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function handler(req, res) {
checkAndCopyConfig("services.yaml");
const servicesYaml = path.join(process.cwd(), "config", "services.yaml");
const fileContents = await fs.readFile(servicesYaml, "utf8");
const services = yaml.load(fileContents);
// map easy to write YAML objects into easy to consume JS arrays
const servicesArray = services.map((group) => {
return {
name: Object.keys(group)[0],
services: group[Object.keys(group)[0]].map((entries) => {
return {
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]],
};
}),
};
});
res.send(servicesArray);
}

@ -0,0 +1,22 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function handler(req, res) {
checkAndCopyConfig("widgets.yaml");
const widgetsYaml = path.join(process.cwd(), "config", "widgets.yaml");
const fileContents = await fs.readFile(widgetsYaml, "utf8");
const widgets = yaml.load(fileContents);
// map easy to write YAML objects into easy to consume JS arrays
const widgetsArray = widgets.map((group) => {
return {
type: Object.