Compare commits

...

94 Commits

Author SHA1 Message Date
Thomas Camlong
1d734633f0 🚑 Merge pull request #301 from ajnart/dev
🚑 Hotfix Docker image with new NextJS version
2022-07-23 00:25:45 +02:00
ajnart
2186756535 🚑 Hotfix Docker image with new NextJS version 2022-07-22 22:27:02 +02:00
Thomas Camlong
702428d24f 🚀 v0.8.2 🐋 Docker fixes and quality of life changes
https://github.com/ajnart/homarr/compare/v0.8.0...v0.8.2
2022-07-22 18:45:13 +02:00
ajnart
c8b0e7013d Improve Calendar module error handling 2022-07-22 18:30:15 +02:00
ajnart
385b4a3b24 🐛 Fix Docker integration actions timeouts 2022-07-22 18:08:32 +02:00
ajnart
5ccdf735ae Hide calendar module sensitive data
Working towards #259
2022-07-22 18:07:36 +02:00
ajnart
81a7789f9c Hide downloads module sensitive data
Working thowards #259
2022-07-22 17:18:33 +02:00
ajnart
a4defd330c 🔥 Remove console.log() statement 2022-07-22 16:21:15 +02:00
ajnart
4628d1d1d7 🚚 Change setCookies to setCookie 2022-07-22 16:20:59 +02:00
ajnart
7b719c2273 🐛 Fix bugs with async events from dockerode 2022-07-22 16:19:56 +02:00
ajnart
a9b840452e ✏️ Fix async data gathering with Dockerode 2022-07-22 16:19:28 +02:00
ajnart
3b0658fee2 Use tryMatchPort 2022-07-22 16:19:07 +02:00
ajnart
b5f1491fbb Add TryMatchPort function and update MatchingImages 2022-07-22 16:18:36 +02:00
ajnart
539903f053 ✏️ Remove resolutions in packagelock 2022-07-22 16:16:09 +02:00
Thomas Camlong
f7aa6338f1 🔀 Merge pull request #296 from RichyHBM/adding-docker-images
Adding docker images to match thanks to @RichyHBM !
2022-07-22 15:18:20 +02:00
Thomas "ajnart" Camlong
f20c2d4472 🔖 Bump version to v0.8.2 2022-07-22 13:20:34 +02:00
Thomas "ajnart" Camlong
d1d13396f8 💄 Linting and prettier 2022-07-22 13:20:02 +02:00
Thomas "ajnart" Camlong
bed08c84de ⬆️ Upgrade layout for new React and NextJS versions 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
c0e1747e09 Make logo text togglable on/off 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ea8df25620 Add searching feature in docker table 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
cd9e844001 🐛 Fix docker not getting all containers
Turned off containers will not be shown
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
8eac0bed84 Improve login page
Styling and responsiveness
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
d2eb31f510 ⬆️ Upgrade 404 page for NextJS latest 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ed72ab6ec7 🐛 Fix middleware due to new NextJS version
Fixes #297
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
02d3766d60 ⬆️ Upgrade next.config.js for new NextJS version 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
5b4f166216 📦 Upgrade to React18 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
75ceab0cf1 🐛 Fix fetching images in MatchIcon 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
91181aed13 🔧 Add vscode debug files 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
3234f06a2d 🐛 Make docker container list scrollable
Fixes #295
2022-07-22 13:18:46 +02:00
Thomas Camlong
cac1059c16 Update feature-request.yml 2022-07-22 13:10:47 +02:00
RichyHBM
632376bed5 Additional hotio images 2022-07-21 18:50:27 +01:00
RichyHBM
64a29e7f4c Put qbittorrent in alphabetical order 2022-07-21 18:47:13 +01:00
RichyHBM
c6d8c9b2d8 Add hotio + other high usage images 2022-07-21 18:43:42 +01:00
RichyHBM
6915a1bfaf Add dashdot and linuxserver docker images to image to match list 2022-07-21 18:29:35 +01:00
Thomas "ajnart" Camlong
00751eeca5 Make discord integration a module
This allows for an error message if the docker integration fails to load
2022-07-21 11:43:43 +02:00
Thomas Camlong
715a4bd6c7 Merge pull request #292 from arghyadipchak/master
Fix Dash. compact view storage
2022-07-21 09:08:00 +02:00
Arghyadip Chakraborty
5df2c67c2f Fix Dash. compact view storage 2022-07-21 00:39:08 +05:30
Thomas Camlong
ce0f27bb6e v0.8.0 Docker 🐋 and Dash. ⚙ integrations !
<!-- Small release message -->

-  Add support for lists in module option by @ajnart in https://github.com/ajnart/homarr/pull/280
- 🔨 Fix Readarr default port number by @Moohan in https://github.com/ajnart/homarr/pull/287
-  Add dash. Integration by @MauriceNino in https://github.com/ajnart/homarr/pull/277
-  Add Docker integration by @ajnart in https://github.com/ajnart/homarr/pull/289

- Dash. (Pronounced Dashdot) is another self-hosted service, made by @MauriceNino that provides a simple way to see stats about your PC in a sleek way
- Docker integration provides a simple way to start, stop, restart and delete containers. To get started, simply mount your docker socket by adding `-v /var/run/docker.sock:/var/run/docker.sock` to your Homarr container !

* @Moohan made their first contribution in https://github.com/ajnart/homarr/pull/287
* @MauriceNino made their first contribution in https://github.com/ajnart/homarr/pull/277

**Full Changelog**: https://github.com/ajnart/homarr/compare/v0.7.2...v0.8.0
2022-07-20 16:49:48 +02:00
Thomas Camlong
5c1a171832 🔀 Merge pull request #289 from ajnart/docker-integration
Add Docker integration 🚀
2022-07-20 15:22:40 +02:00
Thomas "ajnart" Camlong
fd8ab2f643 🔀 Backmerge dev with dash. integration 2022-07-20 15:15:07 +02:00
Thomas Camlong
c750eed5ef Merge branch 'dev' into docker-integration 2022-07-20 15:05:16 +02:00
Thomas "ajnart" Camlong
c446bf1a1f 📦 Update cookies-next package 2022-07-20 15:02:09 +02:00
Thomas Camlong
0fdfa55067 🔀 Merge pull request #277 from MauriceNino/feature/276
Add dash. Integration thanks to @MauriceNino !
2022-07-20 14:52:12 +02:00
Thomas "ajnart" Camlong
c313eacefd 🐛 Fix small bug with the network module 2022-07-20 14:47:51 +02:00
Thomas "ajnart" Camlong
649f7521bc 🔒 Add guard for Docker socket 2022-07-20 14:21:11 +02:00
Thomas "ajnart" Camlong
7065b06c82 💄 Format code 2022-07-20 14:09:47 +02:00
Thomas "ajnart" Camlong
c4e01e482e Add simple image name matching 2022-07-20 14:08:56 +02:00
Thomas Camlong
e56c4b6b56 🔀 Merge pull request #287 from Moohan/master
Fix Readarr default port number
2022-07-11 13:54:01 +02:00
James McMahon
ce38163c6d Fix Readarr default port number
Per https://wiki.servarr.com/readarr
2022-07-11 12:21:28 +01:00
Thomas Camlong
0406d6d5ee Add skeleton while the meto module is loading 2022-07-07 07:13:11 +00:00
Thomas Camlong
4b92c52ea8 Add "Add to homarr" feature and move code 2022-07-06 18:08:39 +02:00
Thomas Camlong
be770d282a ⬆️ Upgrade NextJS version 2022-07-06 18:08:03 +02:00
MauriceNino
0bf95483f9 fix: styles for dash. widget 2022-06-30 16:08:39 +02:00
MauriceNino
60b88389a6 fix: remove leftover console.log 2022-06-30 16:08:39 +02:00
MauriceNino
72832a5767 fix: move enabled options to multi-select 2022-06-30 16:08:39 +02:00
MauriceNino
eb0313f551 fix: transform dash. -> dashdot for icon find 2022-06-30 16:08:39 +02:00
MauriceNino
c0ecc3d4c6 fix: types 2022-06-30 16:08:38 +02:00
MauriceNino
da7b478d81 feat: add dash. integration 2022-06-30 16:08:38 +02:00
Thomas Camlong
2702c9a7cf 🔀 Merge pull request #280 from ajnart/ajnart/issue279
 Add support for lists in module option
2022-06-28 22:33:28 +02:00
Thomas Camlong
3bda6c2b76 🔥 Remove the popover TIP when using the searchbar 2022-06-28 19:09:02 +02:00
Thomas Camlong
1a66bfb8be add a <Tip/> component and use it 2022-06-28 19:08:18 +02:00
Thomas Camlong
41be0e6362 🐛 Fix default values for modules
The default value was not set correctly for modules. This has been fixed. It was also fixed in the Weather Module and the Date Module.
2022-06-28 12:12:39 +02:00
Thomas Camlong
e93a3a3b5f Add support for lists in module option
This feature allows a module maker to use a list as the different possible values for a module integration.
2022-06-28 11:27:23 +02:00
Thomas Camlong
9945ef892e 📱 Fix settings pannels height 2022-06-28 11:06:45 +02:00
Thomas Camlong
812de35149 🐛 Fix a bug where download module was always there 2022-06-28 10:34:25 +02:00
Thomas Camlong
035224b02b add start/stop/restart feature on containers 2022-06-27 23:38:54 +02:00
Thomas Camlong
72aba9d8cd 🚧 Work in progress on the Docker integration 2022-06-27 19:25:26 +02:00
ajnart
df7e833b84 🚧 Work in progress on docker integration 2022-06-27 08:03:40 +02:00
Thomas Camlong
aab1492934 🔖 v0.7.2 2022-06-25 17:53:20 +02:00
Thomas Camlong
1ae074db8f 🔥 Remove .docusaurus/ 2022-06-25 17:51:44 +02:00
Thomas Camlong
f21004e944 🔖 v0.7.2
Tag version v0.7.2
2022-06-25 17:47:39 +02:00
Thomas Camlong
7c421cc52f 🔀 Merge pull request #260 from walkxcode/dev
💄 Changes AppShelf category styling
2022-06-25 16:11:38 +02:00
Thomas Camlong
d8e407ab22 🔀 Merge pull request #257 from ajnart/fix-multiple-torrent-client
🐛 Fix itteration on the different types of services
2022-06-25 15:36:59 +02:00
Thomas Camlong
37565284e6 🔀 Merge pull request #271 from ajnart/searchBar
 Adds query placeholder and autoFocus (#267 #268)
2022-06-25 15:36:06 +02:00
Bjorn Lammers
b758df9f44 🔥 Fix indentation because I'm a perfectionist 2022-06-25 15:14:39 +02:00
Bjorn Lammers
a735ae47c5 🙈 Updates .dockerignore 2022-06-25 15:13:00 +02:00
WalkxCode
97d585dc17 Adds query placeholder and autoFocus (#267 #268) 2022-06-25 14:02:53 +02:00
Thomas Camlong
7f3db9add1 🐛 Fix adding a service doesn't fetch 2022-06-24 13:44:43 +02:00
Thomas Camlong
6d6964f086 🔀 Merge pull request #258 from ajnart/ajnart/issue256
🐛 Allow anything in the input for the form.
2022-06-24 13:39:18 +02:00
Thomas Camlong
2a4012f73a 🔀 Merge pull request #263 from ajnart/#261-discord
💬 Adds Discord Button (#261)
2022-06-24 12:44:23 +02:00
Bjorn Lammers
9385315f03 🔀 Merge pull request #265 from jelliuk/patch-1 2022-06-24 12:06:27 +02:00
James
ee824f0b27 Update README.md
Correct the link to Wiki/Integrations
2022-06-24 10:36:43 +01:00
Bjorn Lammers
792af504c7 💬 Adds Discord Button
#261
2022-06-22 13:19:44 +02:00
Bjorn Lammers
cd3c062a24 💄 Changes AppShelf category styling 2022-06-21 19:14:18 +00:00
ajnart
a5f477c19b 🚑 Hotfix to spread torrent pushing 2022-06-21 21:04:21 +02:00
ajnart
85164d79fc 🚑 Hotfix password and usernames 2022-06-21 20:35:40 +02:00
ajnart
7aedc4111f 🚑 Hotfix how the result from the services are awaited 2022-06-21 19:59:25 +02:00
ajnart
d1f89847f5 💄 Small UI fix for mobile 2022-06-21 19:38:32 +02:00
ajnart
57170847a1 🐛 Allow anything in the input for the form.
If it works, it works.
Fixes #256
2022-06-21 19:22:14 +02:00
ajnart
45de715390 🐛 Fix itteration on the different types of services 2022-06-21 19:16:29 +02:00
Thomas Camlong
c29d6f58dd 🔀 Merge pull request #252 from LarveyOfficial/fix-multiple-download-clients
🐛Allow multiple of the same torrent client +1
2022-06-21 16:22:18 +02:00
ajnart
f0bae49830 🚨 Lint and prettier fix 2022-06-21 16:21:40 +02:00
Larvey
c3ceae4dc6 Also fixed Torrent form fields 2022-06-20 17:26:13 -04:00
Larvey
d654fb39e5 🐛Allow multiple of the same torrent client
Allows multiple of the same type of torrent client
2022-06-20 17:10:54 -04:00
66 changed files with 2947 additions and 10923 deletions

View File

@@ -2,5 +2,8 @@ Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
*.md
.git
.github
LICENSE
docs/

View File

@@ -3,7 +3,6 @@ module.exports = {
'mantine',
'plugin:@next/next/recommended',
'plugin:jest/recommended',
'plugin:storybook/recommended',
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"

View File

@@ -22,13 +22,3 @@ body:
- High (App breaking feature)
validations:
required: true
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You've read the [docs](https://github.com/ajnart/homarr#readme)
required: true
- label: You've checked for [duplicate issues](https://github.com/ajnart/homarr/issues)
required: true

View File

@@ -1,6 +1,8 @@
name: Master docker CI
# Workflow to build and publish docker image
name: Master CI
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [master]
@@ -22,72 +24,46 @@ jobs:
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build:
# Will run yarn install && yarn build
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v3
- name: Get yarn cache directory path
# to help speed up build times
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
# to help speed up build times
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Nextjs cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Cache build output
# to copy needed files to docker build job
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v2
- uses: actions/cache@v2
id: restore-build
uses: actions/checkout@v3
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
@@ -98,10 +74,13 @@ jobs:
tags: |
type=raw,value=latest
type=pep440,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GHCR
uses: docker/login-action@v2
with:
@@ -117,3 +96,5 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -30,6 +30,9 @@ jobs:
# See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Setup
@@ -40,67 +43,32 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
restore-keys: |
${{ runner.os }}-yarn-
- name: Nextjs cache
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Cache build output
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v2
- uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
@@ -134,3 +102,5 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,30 +0,0 @@
module.exports = {
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
addons: [
'@storybook/addon-links',
'storybook-addon-mock/register',
'@storybook/addon-essentials',
],
typescript: {
check: false,
reactDocgen: false,
},
framework: '@storybook/react',
features: { emotionAlias: false },
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// https://github.com/polkadot-js/extension/issues/621#issuecomment-759341776
// framer-motion uses the .mjs notation and we need to include it so that webpack will
// transpile it for us correctly (enables using a CJS module inside an ESM).
config.module.rules.push({
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
});
// Return the altered config
return config;
},
};

View File

@@ -1,16 +0,0 @@
import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications';
export const parameters = { layout: 'fullscreen' };
function ThemeWrapper(props: { children: React.ReactNode }) {
return (
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
<MantineProvider withGlobalStyles withNormalizeCSS>
<NotificationsProvider>{props.children}</NotificationsProvider>
</MantineProvider>
</ColorSchemeProvider>
);
}
export const decorators = [(renderStory: Function) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@@ -1,14 +1,23 @@
FROM node:16-alpine
WORKDIR /app
ENV NODE_ENV production
COPY /next.config.js ./
COPY /public ./public
COPY /package.json ./package.json
# Automatically leverage output traces to reduce image size. https://nextjs.org/docs/advanced-features/output-file-tracing
COPY /.next/standalone ./
COPY /.next/static ./.next/static
EXPOSE 7575
ENV PORT 7575
RUN apk add tzdata
VOLUME /app/data/configs
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
COPY next.config.js ./
COPY public ./public
COPY package.json ./package.json
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY .next/standalone ./
COPY .next/static ./.next/static
EXPOSE 7575
ENV PORT 7575
CMD ["node", "server.js"]

View File

@@ -33,7 +33,7 @@ Homarr is a simple and lightweight homepage for your server, that helps you easi
It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases).
For a full list of integrations look at: [wiki/integrations](#).
For a full list of integrations look at: [wiki/integrations](https://github.com/ajnart/homarr/wiki/Integrations)
If you have any questions about Homarr or want to share information with us, please go to one of the following places:

View File

@@ -18,6 +18,9 @@
},
"Date": {
"enabled": false
},
"Docker": {
"enabled": true
}
}
}

View File

@@ -1,2 +1,2 @@
export const REPO_URL = 'ajnart/homarr';
export const CURRENT_VERSION = 'v0.7.1';
export const CURRENT_VERSION = 'v0.8.2';

View File

@@ -6,11 +6,5 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
module.exports = withBundleAnalyzer({
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
outputStandalone: true,
},
basePath: env.BASE_URL,
output: 'standalone',
});

View File

@@ -1,7 +1,8 @@
{
"name": "homarr",
"version": "0.7.1",
"version": "0.8.2",
"description": "Homarr - A homepage for your server.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ajnart/homarr"
@@ -19,70 +20,63 @@
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
"storybook": "start-storybook -p 7001",
"storybook:build": "build-storybook",
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
},
"dependencies": {
"@ctrl/deluge": "^4.1.0",
"@ctrl/qbittorrent": "^4.0.0",
"@ctrl/shared-torrent": "^4.1.0",
"@ctrl/qbittorrent": "^4.1.0",
"@ctrl/shared-torrent": "^4.1.1",
"@ctrl/transmission": "^4.1.1",
"@dnd-kit/core": "^6.0.1",
"@dnd-kit/sortable": "^7.0.0",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@dnd-kit/utilities": "^3.2.0",
"@mantine/core": "^4.2.8",
"@mantine/dates": "^4.2.8",
"@mantine/dropzone": "^4.2.8",
"@mantine/form": "^4.2.8",
"@mantine/hooks": "^4.2.8",
"@mantine/next": "^4.2.8",
"@mantine/notifications": "^4.2.8",
"@mantine/prism": "^4.2.8",
"@mantine/core": "^4.2.12",
"@mantine/dates": "^4.2.12",
"@mantine/dropzone": "^4.2.12",
"@mantine/form": "^4.2.12",
"@mantine/hooks": "^4.2.12",
"@mantine/next": "^4.2.12",
"@mantine/notifications": "^4.2.12",
"@mantine/prism": "^4.2.12",
"@nivo/core": "^0.79.0",
"@nivo/line": "^0.79.1",
"@tabler/icons": "^1.68.0",
"@tabler/icons": "^1.76.0",
"axios": "^0.27.2",
"cookies-next": "^2.0.4",
"dayjs": "^1.11.3",
"framer-motion": "^6.3.1",
"cookies-next": "^2.1.1",
"dayjs": "^1.11.4",
"dockerode": "^3.3.2",
"framer-motion": "^6.5.1",
"js-file-download": "^0.4.12",
"next": "12.1.6",
"prism-react-renderer": "^1.3.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"systeminformation": "^5.11.16",
"next": "12.2.0",
"prism-react-renderer": "^1.3.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"systeminformation": "^5.12.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/core": "^7.17.8",
"@next/bundle-analyzer": "^12.1.4",
"@next/eslint-plugin-next": "^12.1.4",
"@storybook/react": "^6.5.4",
"@types/node": "^17.0.23",
"@types/react": "17.0.43",
"@next/bundle-analyzer": "12.2.0",
"@next/eslint-plugin-next": "12.2.0",
"@types/dockerode": "^3.3.9",
"@types/node": "^18.0.6",
"@types/react": "^18.0.15",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",
"eslint": "^8.11.0",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"eslint": "^8.20.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-mantine": "1.1.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-storybook": "^0.5.11",
"eslint-plugin-testing-library": "^5.2.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-mantine": "^2.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.6.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.5.1",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "^28.1.0",
"prettier": "^2.6.2",
"require-from-string": "^2.0.2",
"typescript": "4.6.4"
},
"resolutions": {
"@types/react": "17.0.30"
"jest": "^28.1.3",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -1,29 +1,29 @@
import {
Modal,
ActionIcon,
Anchor,
Button,
Center,
Group,
TextInput,
Image,
Button,
Select,
LoadingOverlay,
ActionIcon,
Tooltip,
Title,
Anchor,
Text,
Tabs,
Modal,
MultiSelect,
ScrollArea,
Select,
Switch,
Tabs,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react';
import { IconApps as Apps } from '@tabler/icons';
import { v4 as uuidv4 } from 'uuid';
import { useDebouncedValue } from '@mantine/hooks';
import { IconApps as Apps } from '@tabler/icons';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useConfig } from '../../tools/state';
import { ServiceTypeList, StatusCodes } from '../../tools/types';
import { tryMatchPort, ServiceTypeList, StatusCodes } from '../../tools/types';
import Tip from '../layout/Tip';
export function AddItemShelfButton(props: any) {
const [opened, setOpened] = useState(false);
@@ -54,11 +54,13 @@ export function AddItemShelfButton(props: any) {
);
}
function MatchIcon(name: string, form: any) {
function MatchIcon(name: string | undefined, form: any) {
if (name === undefined || name === '') return null;
fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`
.toLowerCase()
.replace(/^dash\.$/, 'dashdot')}.png`
).then((res) => {
if (res.ok) {
form.setFieldValue('icon', res.url);
@@ -75,22 +77,7 @@ function MatchService(name: string, form: any) {
}
}
function MatchPort(name: string, form: any) {
const portmap = [
{ name: 'qbittorrent', value: '8080' },
{ name: 'sonarr', value: '8989' },
{ name: 'radarr', value: '7878' },
{ name: 'lidarr', value: '8686' },
{ name: 'readarr', value: '8686' },
{ name: 'deluge', value: '8112' },
{ name: 'transmission', value: '9091' },
];
// Match name with portmap key
const port = portmap.find((p) => p.name === name.toLowerCase());
if (port) {
form.setFieldValue('url', `http://localhost:${port.value}`);
}
}
const DEFAULT_ICON = '/favicon.svg';
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
const { setOpened } = props;
@@ -111,7 +98,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
type: props.type ?? 'Other',
category: props.category ?? undefined,
name: props.name ?? '',
icon: props.icon ?? '/favicon.svg',
icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '',
apiKey: props.apiKey ?? (undefined as unknown as string),
username: props.username ?? (undefined as unknown as string),
@@ -123,13 +110,9 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
validate: {
apiKey: () => null,
// Validate icon with a regex
icon: (value: string) => {
// Regex to match everything that ends with and icon extension
if (!value.match(/\.(png|jpg|jpeg|gif|svg)$/)) {
return 'Please enter a valid icon URL';
}
return null;
},
icon: (value: string) =>
// Disable matching to allow any values
null,
// Validate url with a regex http/https
url: (value: string) => {
try {
@@ -150,10 +133,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (form.values.name !== debounced || props.name || props.type) return;
if (form.values.name !== debounced || form.values.icon !== DEFAULT_ICON) return;
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
MatchPort(form.values.name, form);
tryMatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname)
@@ -223,7 +206,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<TextInput
required
label="Icon URL"
placeholder="/favicon.svg"
placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')}
/>
<TextInput
@@ -277,15 +260,8 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
}}
error={form.errors.apiKey && 'Invalid API key'}
/>
<Text
style={{
alignSelf: 'center',
fontSize: '0.75rem',
textAlign: 'center',
color: 'gray',
}}
>
Tip: Get your API key{' '}
<Tip>
Get your API key{' '}
<Anchor
target="_blank"
weight="bold"
@@ -294,7 +270,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
>
here.
</Anchor>
</Text>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
@@ -321,9 +297,20 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
/>
</>
)}
{(form.values.type === 'Deluge' ||
form.values.type === 'Transmission' ||
form.values.type === 'qBittorrent') && (
{form.values.type === 'Deluge' && (
<>
<TextInput
label="Password"
placeholder="password"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label="Username"
@@ -336,7 +323,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
/>
<TextInput
label="Password"
placeholder="password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);

View File

@@ -1,27 +0,0 @@
import { SimpleGrid } from '@mantine/core';
import AppShelf from './AppShelf';
import { AppShelfItem } from './AppShelfItem';
export default {
title: 'Item Shelf',
component: AppShelf,
args: {
service: {
name: 'qBittorrent',
url: 'http://',
icon: 'https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/qBittorrent/icon.png',
type: 'qBittorrent',
apiKey: '',
},
},
};
export const Default = (args: any) => <AppShelf {...args} />;
export const One = (args: any) => <AppShelfItem {...args} />;
export const Ten = (args: any) => (
<SimpleGrid>
{Array.from(Array(10)).map((_, i) => (
<AppShelfItem {...args} key={i} />
))}
</SimpleGrid>
);

View File

@@ -20,15 +20,30 @@ import DownloadComponent from '../modules/downloads/DownloadsModule';
const useStyles = createStyles((theme, _params) => ({
item: {
borderBottom: 0,
overflow: 'hidden',
border: '1px solid transparent',
borderRadius: theme.radius.lg,
borderLeft: '3px solid transparent',
borderRight: '3px solid transparent',
borderBottom: '3px solid transparent',
borderRadius: '20px',
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
marginTop: theme.spacing.md,
},
itemOpened: {
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3],
control: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
borderRadius: theme.spacing.md,
'&:hover': {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
},
},
content: {
margin: theme.spacing.md,
},
label: {
overflow: 'visible',
},
}));
@@ -137,6 +152,7 @@ const AppShelf = (props: any) => {
const noCategory = config.services.filter(
(e) => e.category === undefined || e.category === null
);
const downloadEnabled = config.modules?.[DownloadsModule.title]?.enabled ?? false;
// Create an item with 0: true, 1: true, 2: true... For each category
return (
// Return one item for each category
@@ -147,11 +163,6 @@ const AppShelf = (props: any) => {
order={2}
iconPosition="right"
multiple
styles={{
item: {
borderRadius: '20px',
},
}}
initialState={toggledCategories}
onChange={(idx) => settoggledCategories(idx)}
>
@@ -166,21 +177,23 @@ const AppShelf = (props: any) => {
{item()}
</Accordion.Item>
) : null}
<Accordion.Item key="Downloads" label="Your downloads">
<Paper
p="lg"
radius="lg"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
{downloadEnabled ? (
<Accordion.Item key="Downloads" label="Your downloads">
<Paper
p="lg"
radius="lg"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<ModuleMenu module={DownloadsModule} />
<DownloadComponent />
</Paper>
</Accordion.Item>
}}
>
<ModuleMenu module={DownloadsModule} />
<DownloadComponent />
</Paper>
</Accordion.Item>
) : null}
</Accordion>
</Group>
);

View File

@@ -20,11 +20,7 @@ export default function AppShelfMenu(props: any) {
onClose={() => setOpened(false)}
title="Modify a service"
>
<AddAppShelfItemForm
setOpened={setOpened}
{...service}
message="Save service"
/>
<AddAppShelfItemForm setOpened={setOpened} {...service} message="Save service" />
</Modal>
<Menu
position="right"

View File

@@ -1,5 +1,5 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core';
import { setCookies } from 'cookies-next';
import { setCookie } from 'cookies-next';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
@@ -26,7 +26,7 @@ export default function ConfigChanger() {
label="Config loader"
onChange={(e) => {
loadConfig(e ?? 'default');
setCookies('config-name', e ?? 'default', {
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});

View File

@@ -10,7 +10,7 @@ import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications';
import { useRef } from 'react';
import { useRouter } from 'next/router';
import { setCookies } from 'cookies-next';
import { setCookie } from 'cookies-next';
import { useConfig } from '../../tools/state';
import { Config } from '../../tools/types';
import { migrateToIdConfig } from '../../tools/migrate';
@@ -90,7 +90,7 @@ export default function LoadConfigComponent(props: any) {
icon: <Check />,
message: undefined,
});
setCookies('config-name', newConfig.name, {
setCookie('config-name', newConfig.name, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});

View File

@@ -37,7 +37,7 @@ export default function TitleChanger() {
};
return (
<Group direction="column" grow>
<Group direction="column" grow mb="lg">
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
<Group grow direction="column">
<TextInput label="Page title" placeholder="Homarr 🦞" {...form.getInputProps('title')} />

View File

@@ -1,13 +1,12 @@
import { ActionIcon, Group, Text, SegmentedControl, TextInput, Anchor } from '@mantine/core';
import { Group, Text, SegmentedControl, TextInput } from '@mantine/core';
import { useState } from 'react';
import { IconBrandGithub as BrandGithub } from '@tabler/icons';
import { CURRENT_VERSION } from '../../../data/constants';
import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch';
import ConfigChanger from '../Config/ConfigChanger';
import SaveConfigComponent from '../Config/SaveConfig';
import ModuleEnabler from './ModuleEnabler';
import Tip from '../layout/Tip';
export default function CommonSettings(args: any) {
const { config, setConfig } = useConfig();
@@ -25,11 +24,16 @@ export default function CommonSettings(args: any) {
);
return (
<Group direction="column" grow>
<Group direction="column" grow mb="lg">
<Group grow direction="column" spacing={0}>
<Text>Search engine</Text>
<Tip>
Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube or
for a Torrent respectively.
</Tip>
<SegmentedControl
fullWidth
mb="sm"
title="Search engine"
value={
// Match config.settings.searchUrl with a key in the matches array
@@ -51,21 +55,24 @@ export default function CommonSettings(args: any) {
data={matches}
/>
{searchUrl === 'Custom' && (
<TextInput
label="Query URL"
placeholder="Custom query url"
value={customSearchUrl}
onChange={(event) => {
setCustomSearchUrl(event.currentTarget.value);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: event.currentTarget.value,
},
});
}}
/>
<>
<Tip>%s can be used as a placeholder for the query.</Tip>
<TextInput
label="Query URL"
placeholder="Custom query URL"
value={customSearchUrl}
onChange={(event) => {
setCustomSearchUrl(event.currentTarget.value);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: event.currentTarget.value,
},
});
}}
/>
</>
)}
</Group>
<ColorSchemeSwitch />
@@ -73,47 +80,7 @@ export default function CommonSettings(args: any) {
<ModuleEnabler />
<ConfigChanger />
<SaveConfigComponent />
<Text
style={{
alignSelf: 'center',
fontSize: '0.75rem',
textAlign: 'center',
color: 'gray',
}}
>
Tip: You can upload your config file by dragging and dropping it onto the page!
</Text>
<Group position="center" direction="row" mr="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<BrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
</Group>
<Tip>Upload your config file by dragging and dropping it onto the page!</Tip>
</Group>
);
}

View File

@@ -0,0 +1,44 @@
import { Group, ActionIcon, Anchor, Text } from '@mantine/core';
import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons';
import { CURRENT_VERSION } from '../../../data/constants';
export default function Credits(props: any) {
return (
<Group position="center" direction="row" mr="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<IconBrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Group spacing={1}>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
<ActionIcon<'a'> component="a" href="https://discord.gg/aCsmEV5RgA" size="lg">
<IconBrandDiscord size={18} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -1,10 +0,0 @@
import { SettingsMenuButton } from './SettingsMenu';
export default {
title: ' menu',
args: {
opened: false,
},
};
export const Default = (args: any) => <SettingsMenuButton {...args} />;

View File

@@ -1,18 +1,23 @@
import { ActionIcon, Title, Tooltip, Drawer, Tabs } from '@mantine/core';
import { ActionIcon, Title, Tooltip, Drawer, Tabs, ScrollArea } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { useState } from 'react';
import { IconSettings } from '@tabler/icons';
import AdvancedSettings from './AdvancedSettings';
import CommonSettings from './CommonSettings';
import Credits from './Credits';
function SettingsMenu(props: any) {
return (
<Tabs grow>
<Tabs.Tab data-autofocus label="Common">
<CommonSettings />
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<CommonSettings />
</ScrollArea>
</Tabs.Tab>
<Tabs.Tab label="Customizations">
<AdvancedSettings />
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<AdvancedSettings />
</ScrollArea>
</Tabs.Tab>
</Tabs>
);
@@ -26,13 +31,14 @@ export function SettingsMenuButton(props: any) {
<>
<Drawer
size="xl"
padding="xl"
padding="lg"
position="right"
title={<Title order={3}>Settings</Title>}
title={<Title order={5}>Settings</Title>}
opened={props.opened || opened}
onClose={() => setOpened(false)}
>
<SettingsMenu />
<Credits />
</Drawer>
<ActionIcon
variant="default"

View File

@@ -1,23 +1,11 @@
import React from 'react';
import {
createStyles,
Header as Head,
Group,
Box,
Burger,
Drawer,
Title,
ScrollArea,
ActionIcon,
Transition,
} from '@mantine/core';
import { Box, createStyles, Group, Header as Head } from '@mantine/core';
import { useBooleanToggle } from '@mantine/hooks';
import { Logo } from './Logo';
import SearchBar from '../modules/search/SearchModule';
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
import DockerMenuButton from '../modules/docker/DockerModule';
import SearchBar from '../modules/search/SearchModule';
import { SettingsMenuButton } from '../Settings/SettingsMenu';
import { ModuleWrapper } from '../modules/moduleWrapper';
import { CalendarModule, TotalDownloadsModule, WeatherModule, DateModule } from '../modules';
import { Logo } from './Logo';
const HEADER_HEIGHT = 60;
@@ -47,49 +35,9 @@ export function Header(props: any) {
</Box>
<Group noWrap>
<SearchBar />
<DockerMenuButton />
<SettingsMenuButton />
<AddItemShelfButton />
<ActionIcon className={classes.burger} variant="default" radius="md" size="xl">
<Burger
opened={!hidden}
onClick={(_) => {
toggleHidden();
toggleOpened();
}}
/>
</ActionIcon>
<Drawer
size="auto"
padding="xl"
position="right"
hidden={hidden}
title={<Title order={3}>Modules</Title>}
opened
onClose={() => {
toggleHidden();
}}
>
<Transition
mounted={opened}
transition="pop-top-right"
duration={300}
timingFunction="ease"
onExit={() => toggleOpened()}
>
{(styles) => (
<div style={styles}>
<ScrollArea style={{ height: '90vh' }}>
<Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
</Group>
</ScrollArea>
</div>
)}
</Transition>
</Drawer>
</Group>
</Group>
</Head>

View File

@@ -19,8 +19,8 @@ export default function Layout({ children, style }: any) {
return (
<AppShell
header={<Header />}
navbar={widgetPosition ? <Navbar /> : <></>}
aside={widgetPosition ? <></> : <Aside />}
navbar={widgetPosition ? <Navbar /> : undefined}
aside={widgetPosition ? undefined : <Aside />}
footer={<Footer links={[]} />}
>
<HeaderConfig />

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
export function Logo({ style }: any) {
export function Logo({ style, withoutText }: any) {
const { config } = useConfig();
const { primaryColor, secondaryColor } = useColorTheme();
@@ -17,26 +17,28 @@ export function Logo({ style }: any) {
position: 'relative',
}}
/>
<NextLink
href="/"
style={{
textDecoration: 'none',
position: 'relative',
}}
>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{
from: primaryColor,
to: secondaryColor,
deg: 145,
{withoutText ? null : (
<NextLink
href="/"
style={{
textDecoration: 'none',
position: 'relative',
}}
>
{config.settings.title || 'Homarr'}
</Text>
</NextLink>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{
from: primaryColor,
to: secondaryColor,
deg: 145,
}}
>
{config.settings.title || 'Homarr'}
</Text>
</NextLink>
)}
</Group>
);
}

View File

@@ -0,0 +1,19 @@
import { Text } from '@mantine/core';
interface TipProps {
children: React.ReactNode;
}
export default function Tip(props: TipProps) {
return (
<Text
style={{
fontSize: '0.75rem',
color: 'gray',
marginBottom: '0.5rem',
}}
>
Tip: {props.children}
</Text>
);
}

View File

@@ -1,21 +1,16 @@
import { Group } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules';
import { DashdotModule } from '../modules/dash.';
import { ModuleWrapper } from '../modules/moduleWrapper';
export default function Widgets(props: any) {
const matches = useMediaQuery('(min-width: 800px)');
return (
<>
{matches && (
<Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
</Group>
)}
</>
<Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Group>
);
}

View File

@@ -1,7 +0,0 @@
import CalendarComponent from './CalendarModule';
export default {
title: 'Calendar component',
};
export const Default = (args: any) => <CalendarComponent {...args} />;

View File

@@ -63,7 +63,7 @@ export default function CalendarComponent(props: any) {
if (!service || !service.apiKey) {
return Promise.resolve({ data: [] });
}
return axios.post(`/api/modules/calendar?type=${type}`, { ...service });
return axios.post(`/api/modules/calendar?type=${type}`, { id: service.id });
}
useEffect(() => {
@@ -71,11 +71,13 @@ export default function CalendarComponent(props: any) {
const currentSonarrMedias: any[] = [];
Promise.all(
sonarrServices.map((service) =>
getMedias(service, 'sonarr').then((res) => {
currentSonarrMedias.push(...res.data);
}).catch(() => {
currentSonarrMedias.push([]);
})
getMedias(service, 'sonarr')
.then((res) => {
currentSonarrMedias.push(...res.data);
})
.catch(() => {
currentSonarrMedias.push([]);
})
)
).then(() => {
setSonarrMedias(currentSonarrMedias);
@@ -83,11 +85,13 @@ export default function CalendarComponent(props: any) {
const currentRadarrMedias: any[] = [];
Promise.all(
radarrServices.map((service) =>
getMedias(service, 'radarr').then((res) => {
currentRadarrMedias.push(...res.data);
}).catch(() => {
currentRadarrMedias.push([]);
})
getMedias(service, 'radarr')
.then((res) => {
currentRadarrMedias.push(...res.data);
})
.catch(() => {
currentRadarrMedias.push([]);
})
)
).then(() => {
setRadarrMedias(currentRadarrMedias);
@@ -95,11 +99,13 @@ export default function CalendarComponent(props: any) {
const currentLidarrMedias: any[] = [];
Promise.all(
lidarrServices.map((service) =>
getMedias(service, 'lidarr').then((res) => {
currentLidarrMedias.push(...res.data);
}).catch(() => {
currentLidarrMedias.push([]);
})
getMedias(service, 'lidarr')
.then((res) => {
currentLidarrMedias.push(...res.data);
})
.catch(() => {
currentLidarrMedias.push([]);
})
)
).then(() => {
setLidarrMedias(currentLidarrMedias);
@@ -107,11 +113,13 @@ export default function CalendarComponent(props: any) {
const currentReadarrMedias: any[] = [];
Promise.all(
readarrServices.map((service) =>
getMedias(service, 'readarr').then((res) => {
currentReadarrMedias.push(...res.data);
}).catch(() => {
currentReadarrMedias.push([]);
})
getMedias(service, 'readarr')
.then((res) => {
currentReadarrMedias.push(...res.data);
})
.catch(() => {
currentReadarrMedias.push([]);
})
)
).then(() => {
setReadarrMedias(currentReadarrMedias);

View File

@@ -1,67 +0,0 @@
import { RadarrMediaDisplay } from './MediaDisplay';
export default {
title: 'Media display component',
args: {
media: {
title: 'Doctor Strange in the Multiverse of Madness',
originalTitle: 'Doctor Strange in the Multiverse of Madness',
originalLanguage: {
id: 1,
name: 'English',
},
secondaryYearSourceId: 0,
sortTitle: 'doctor strange in multiverse madness',
sizeOnDisk: 0,
status: 'announced',
overview:
'Doctor Strange, with the help of mystical allies both old and new, traverses the mind-bending and dangerous alternate realities of the Multiverse to confront a mysterious new adversary.',
inCinemas: '2022-05-04T00:00:00Z',
images: [
{
coverType: 'poster',
url: 'https://image.tmdb.org/t/p/original/wRnbWt44nKjsFPrqSmwYki5vZtF.jpg',
},
{
coverType: 'fanart',
url: 'https://image.tmdb.org/t/p/original/ndCSoasjIZAMMDIuMxuGnNWu4DU.jpg',
},
],
website: 'https://www.marvel.com/movies/doctor-strange-in-the-multiverse-of-madness',
year: 2022,
hasFile: false,
youTubeTrailerId: 'aWzlQ2N6qqg',
studio: 'Marvel Studios',
path: '/config/Doctor Strange in the Multiverse of Madness (2022)',
qualityProfileId: 1,
monitored: true,
minimumAvailability: 'announced',
isAvailable: true,
folderName: '/config/Doctor Strange in the Multiverse of Madness (2022)',
runtime: 126,
cleanTitle: 'doctorstrangeinmultiversemadness',
imdbId: 'tt9419884',
tmdbId: 453395,
titleSlug: '453395',
certification: 'PG-13',
genres: ['Fantasy', 'Action', 'Adventure'],
tags: [],
added: '2022-04-29T20:52:33Z',
ratings: {
tmdb: {
votes: 0,
value: 0,
type: 'user',
},
},
collection: {
name: 'Doctor Strange Collection',
tmdbId: 618529,
images: [],
},
id: 1,
},
},
};
export const Default = (args: any) => <RadarrMediaDisplay {...args} />;

View File

@@ -0,0 +1,233 @@
import { createStyles, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
import { IModule } from '../modules';
const asModule = <T extends IModule>(t: T) => t;
export const DashdotModule = asModule({
title: 'Dash.',
description: 'A module for displaying the graphs of your running Dash. instance.',
icon: CalendarIcon,
component: DashdotComponent,
options: {
cpuMultiView: {
name: 'CPU Multi-Core View',
value: false,
},
storageMultiView: {
name: 'Storage Multi-Drive View',
value: false,
},
useCompactView: {
name: 'Use Compact View',
value: false,
},
graphs: {
name: 'Graphs',
value: ['CPU', 'RAM', 'Storage', 'Network'],
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
},
},
});
const useStyles = createStyles((theme, _params) => ({
heading: {
marginTop: 0,
marginBottom: 10,
},
table: {
display: 'table',
},
tableRow: {
display: 'table-row',
},
tableLabel: {
display: 'table-cell',
paddingRight: 10,
},
tableValue: {
display: 'table-cell',
whiteSpace: 'pre-wrap',
paddingBottom: 5,
},
graphsContainer: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
rowGap: 10,
columnGap: 10,
},
iframe: {
flex: '1 0 auto',
maxWidth: '100%',
height: '140px',
borderRadius: theme.radius.lg,
},
}));
const bpsPrettyPrint = (bits?: number) =>
!bits
? '-'
: bits > 1000 * 1000 * 1000
? `${(bits / 1000 / 1000 / 1000).toFixed(1)} Gb/s`
: bits > 1000 * 1000
? `${(bits / 1000 / 1000).toFixed(1)} Mb/s`
: bits > 1000
? `${(bits / 1000).toFixed(1)} Kb/s`
: `${bits.toFixed(1)} b/s`;
const bytePrettyPrint = (byte: number): string =>
byte > 1024 * 1024 * 1024
? `${(byte / 1024 / 1024 / 1024).toFixed(1)} GiB`
: byte > 1024 * 1024
? `${(byte / 1024 / 1024).toFixed(1)} MiB`
: byte > 1024
? `${(byte / 1024).toFixed(1)} KiB`
: `${byte.toFixed(1)} B`;
const useJson = (service: serviceItem | undefined, url: string) => {
const [data, setData] = useState<any | undefined>();
const doRequest = async () => {
try {
const resp = await axios.get(url, { baseURL: service?.url });
setData(resp.data);
// eslint-disable-next-line no-empty
} catch (e) {}
};
useEffect(() => {
if (service?.url) {
doRequest();
}
}, [service?.url]);
return data;
};
export function DashdotComponent() {
const { config } = useConfig();
const theme = useMantineTheme();
const { classes } = useStyles();
const { colorScheme } = useMantineColorScheme();
const dashConfig = config.modules?.[DashdotModule.title]
.options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService = config.services.filter((service) => service.type === 'Dash.')[0];
const enabledGraphs = dashConfig?.graphs?.value ?? ['CPU', 'RAM', 'Storage', 'Network'];
const cpuEnabled = enabledGraphs.includes('CPU');
const storageEnabled = enabledGraphs.includes('Storage');
const ramEnabled = enabledGraphs.includes('RAM');
const networkEnabled = enabledGraphs.includes('Network');
const gpuEnabled = enabledGraphs.includes('GPU');
const info = useJson(dashdotService, '/info');
const storageLoad = useJson(dashdotService, '/load/storage');
const totalUsed =
(storageLoad?.layout as any[])?.reduce((acc, curr) => (curr.load ?? 0) + acc, 0) ?? 0;
const totalSize =
(info?.storage?.layout as any[])?.reduce((acc, curr) => (curr.size ?? 0) + acc, 0) ?? 0;
const graphs = [
{
name: 'CPU',
enabled: cpuEnabled,
params: {
multiView: dashConfig?.cpuMultiView?.value ?? false,
},
},
{
name: 'Storage',
enabled: storageEnabled && !isCompact,
params: {
multiView: dashConfig?.storageMultiView?.value ?? false,
},
},
{
name: 'RAM',
enabled: ramEnabled,
},
{
name: 'Network',
enabled: networkEnabled,
spanTwo: true,
},
{
name: 'GPU',
enabled: gpuEnabled,
spanTwo: true,
},
].filter((g) => g.enabled);
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
{!dashdotService ? (
<p>No dash. service found. Please add one to your Homarr dashboard.</p>
) : !info ? (
<p>Cannot acquire information from dash. - are you running the latest version?</p>
) : (
<div className={classes.graphsContainer}>
<div className={classes.table}>
{storageEnabled && isCompact && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Storage:</p>
<p className={classes.tableValue}>
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
</p>
</div>
)}
{networkEnabled && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Network:</p>
<p className={classes.tableValue}>
{bpsPrettyPrint(info?.network?.speedUp)} Up{'\n'}
{bpsPrettyPrint(info?.network?.speedDown)} Down
</p>
</div>
)}
</div>
{graphs.map((graph) => (
<iframe
className={classes.iframe}
style={
isCompact
? {
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
}
: undefined
}
key={graph.name}
title={graph.name}
src={`${
dashdotService.url
}?singleGraphMode=true&graph=${graph.name.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
'dark'
? theme.colors.dark[7]
: theme.colors.gray[0]
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
graph.params
? `&${Object.entries(graph.params)
.map(([key, value]) => `${key}=${value.toString()}`)
.join('&')}`
: ''
}`}
frameBorder="0"
allowTransparency
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { DashdotModule } from './DashdotModule';

View File

@@ -1,7 +0,0 @@
import DateComponent from './DateModule';
export default {
title: 'Date module',
};
export const Default = (args: any) => <DateComponent {...args} />;

View File

@@ -23,7 +23,7 @@ export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? true;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)

View File

@@ -0,0 +1,164 @@
import { Button, Group, Modal, Title } from '@mantine/core';
import { useBooleanToggle } from '@mantine/hooks';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
import { tryMatchService } from '../../../tools/addToHomarr';
import { AddAppShelfItemForm } from '../../AppShelf/AddAppShelfItem';
function sendDockerCommand(
action: string,
containerId: string,
containerName: string,
reload: () => void
) {
showNotification({
id: containerId,
loading: true,
title: `${action}ing container ${containerName}`,
message: undefined,
autoClose: false,
disallowClose: true,
});
axios
.get(`/api/docker/container/${containerId}?action=${action}`)
.then((res) => {
updateNotification({
id: containerId,
title: `Container ${containerName} ${action}ed`,
message: `Your container was successfully ${action}ed`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: containerId,
color: 'red',
title: 'There was an error',
message: err.response.data.reason,
autoClose: 2000,
});
})
.finally(() => {
reload();
});
}
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
reload: () => void;
}
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
const [opened, setOpened] = useBooleanToggle(false);
return (
<Group>
<Modal
size="xl"
radius="md"
opened={opened}
onClose={() => setOpened(false)}
title="Add service"
>
<AddAppShelfItemForm
setOpened={setOpened}
{...tryMatchService(selected.at(0))}
message="Add service to homarr"
/>
</Modal>
<Button
leftIcon={<IconRotateClockwise />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="orange"
radius="md"
>
Restart
</Button>
<Button
leftIcon={<IconPlayerStop />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="red"
radius="md"
>
Stop
</Button>
<Button
leftIcon={<IconPlayerPlay />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="green"
radius="md"
>
Start
</Button>
<Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light" radius="md">
Refresh data
</Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
onClick={() => {
if (selected.length !== 1) {
showNotification({
autoClose: 5000,
title: <Title order={5}>Please only add one service at a time!</Title>,
color: 'red',
message: undefined,
});
} else {
setOpened(true);
}
}}
>
Add to Homarr
</Button>
<Button
leftIcon={<IconTrash />}
color="red"
variant="light"
radius="md"
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload)
)
)
}
>
Remove
</Button>
</Group>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge, BadgeVariant, MantineSize } from '@mantine/core';
import Dockerode from 'dockerode';
export interface ContainerStateProps {
state: Dockerode.ContainerInfo['State'];
}
export default function ContainerState(props: ContainerStateProps) {
const { state } = props;
const options: {
size: MantineSize;
radius: MantineSize;
variant: BadgeVariant;
} = {
size: 'md',
radius: 'md',
variant: 'outline',
};
switch (state) {
case 'running': {
return (
<Badge color="green" {...options}>
Running
</Badge>
);
}
case 'created': {
return (
<Badge color="cyan" {...options}>
Created
</Badge>
);
}
case 'exited': {
return (
<Badge color="red" {...options}>
Stopped
</Badge>
);
}
default: {
return (
<Badge color="purple" {...options}>
Unknown
</Badge>
);
}
}
}

View File

@@ -0,0 +1,84 @@
import { ActionIcon, Drawer, Group, LoadingOverlay, Text } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import { IconBrandDocker, IconX } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
export const DockerModule: IModule = {
title: 'Docker',
description: 'Allows you to easily manage your torrents',
icon: IconBrandDocker,
component: DockerMenuButton,
};
export default function DockerMenuButton(props: any) {
const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const [visible, setVisible] = useState(false);
const { config } = useConfig();
const moduleEnabled = config.modules?.[DockerModule.title]?.enabled ?? false;
useEffect(() => {
reload();
}, [config.modules]);
function reload() {
if (!moduleEnabled) {
return;
}
setVisible(true);
setTimeout(() => {
axios
.get('/api/docker/containers')
.then((res) => {
setContainers(res.data);
setSelection([]);
setVisible(false);
})
.catch(() =>
// Send an Error notification
showNotification({
autoClose: 1500,
title: <Text>Docker integration failed</Text>,
color: 'red',
icon: <IconX />,
message: 'Did you forget to mount the docker socket ?',
})
);
}, 300);
}
const exists = config.modules?.[DockerModule.title]?.enabled ?? false;
if (!exists) {
return null;
}
// Check if the user has at least one container
if (containers.length < 1) return null;
return (
<>
<Drawer opened={opened} onClose={() => setOpened(false)} padding="xl" size="full">
<ContainerActionBar selected={selection} reload={reload} />
<div style={{ position: 'relative' }}>
<LoadingOverlay transitionDuration={500} visible={visible} />
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</div>
</Drawer>
<Group position="center">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Group>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { Table, Checkbox, Group, Badge, createStyles, ScrollArea, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode';
import { useEffect, useState } from 'react';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
const { classes, cx } = useStyles();
const [search, setSearch] = useState('');
useEffect(() => {
setContainers(containers);
}, [containers]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setContainers(filterContainers(containers, value));
};
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
);
}
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === usedContainers.length ? [] : usedContainers.map((c) => c)
);
const rows = usedContainers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
// Remove duplicates with filter function
.filter(
(port, index, self) =>
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<ScrollArea style={{ height: '80vh' }}>
<TextInput
placeholder="Search by container or image name"
mt="md"
icon={<IconSearch size={14} />}
value={search}
onChange={handleSearchChange}
/>
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
<caption>your docker containers</caption>
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === usedContainers.length}
indeterminate={selection.length > 0 && selection.length !== usedContainers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -0,0 +1 @@
export { DockerModule } from './DockerModule';

View File

@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useViewportSize } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IModule } from '../modules';
import { useConfig } from '../../../tools/state';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
@@ -52,14 +53,32 @@ export default function DownloadComponent() {
useEffect(() => {
setIsLoading(true);
if (downloadServices.length === 0) return;
setSafeInterval(() => {
const interval = setInterval(() => {
// Send one request with each download service inside
axios.post('/api/modules/downloads', { config }).then((response) => {
setTorrents(response.data);
setIsLoading(false);
});
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
setIsLoading(false);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
setIsLoading(false);
showNotification({
title: 'Error fetching torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-downloads-module',
color: 'red',
message:
'Please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 5000);
}, [config.services]);
}, []);
if (downloadServices.length === 0) {
return (

View File

@@ -6,6 +6,7 @@ import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { linearGradientDef } from '@nivo/core';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { useConfig } from '../../../tools/state';
import { humanFileSize } from '../../../tools/humanFileSize';
@@ -42,11 +43,28 @@ export default function TotalDownloadsComponent() {
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
useEffect(() => {
if (downloadServices.length === 0) return;
setSafeInterval(() => {
axios.post('/api/modules/downloads', { config }).then((response) => {
setTorrents(response.data);
});
const interval = setSafeInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
showNotification({
title: 'Torrent speed module failed to fetch torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-speed-module',
color: 'red',
message:
'Error fetching torrents, please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 1000);
}, [config.services]);

View File

@@ -1,6 +1,8 @@
export * from './date';
export * from './calendar';
export * from './search';
export * from './ping';
export * from './weather';
export * from './dash.';
export * from './date';
export * from './downloads';
export * from './ping';
export * from './search';
export * from './weather';
export * from './docker';

View File

@@ -1,10 +1,18 @@
import { Button, Card, Group, Menu, Switch, TextInput, useMantineColorScheme } from '@mantine/core';
import {
Button,
Card,
Group,
Menu,
MultiSelect,
Switch,
TextInput,
useMantineColorScheme,
} from '@mantine/core';
import { useConfig } from '../../tools/state';
import { IModule } from './modules';
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
const items: JSX.Element[] = [];
if (module.options) {
const keys = Object.keys(module.options);
@@ -15,6 +23,38 @@ function getItems(module: IModule) {
types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title];
if (type === 'object') {
items.push(
<MultiSelect
label={module.options?.[keys[index]].name}
data={module.options?.[keys[index]].options ?? []}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string[]) ??
(values[index].value as string[]) ??
[]
}
searchable
onChange={(value) => {
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...moduleInConfig,
options: {
...moduleInConfig?.options,
[keys[index]]: {
...moduleInConfig?.options?.[keys[index]],
value,
},
},
},
},
});
}}
/>
);
}
if (type === 'string') {
items.push(
<form
@@ -44,7 +84,11 @@ function getItems(module: IModule) {
id={optionName}
name={optionName}
label={values[index].name}
defaultValue={(moduleInConfig?.options?.[keys[index]]?.value as string) ?? ''}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string) ??
(values[index].value as string) ??
''
}
onChange={(e) => {}}
/>
@@ -59,7 +103,9 @@ function getItems(module: IModule) {
<Switch
defaultChecked={
// Set default checked to the value of the option if it exists
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ?? false
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ??
(values[index].value as boolean) ??
false
}
key={keys[index]}
onClick={(e) => {

View File

@@ -1,11 +1,14 @@
// This interface is to be used in all the modules of the project
// Each module should have its own interface and call the following function:
// TODO: Add a function to register a module
import { TablerIcon } from '@tabler/icons';
// Note: Maybe use context to keep track of the modules
export interface IModule {
title: string;
description: string;
icon: React.ReactNode;
icon: TablerIcon;
component: React.ComponentType;
options?: Option;
}
@@ -16,5 +19,6 @@ interface Option {
export interface OptionValues {
name: string;
value: boolean | string;
value: boolean | string | string[];
options?: string[];
}

View File

@@ -1,18 +0,0 @@
import { serviceItem } from '../../../tools/types';
import PingComponent from './PingModule';
export default {
title: 'Modules/Search bar',
};
const service: serviceItem = {
id: '1',
type: 'Other',
name: 'YouTube',
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',
url: 'https://youtube.com/',
status: ['200'],
newTab: false,
};
export const Default = (args: any) => <PingComponent service={service} />;

View File

@@ -23,19 +23,19 @@ export default function PingComponent(props: any) {
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
function statusCheck(response: AxiosResponse) {
const { status }: {status: string[]} = props;
const { status }: { status: string[] } = props;
//Default Status
let acceptableStatus = ['200'];
if (status !== undefined && status.length) {
acceptableStatus = status;
}
// Checks if reported status is in acceptable status array
if (acceptableStatus.indexOf((response.status).toString()) >= 0) {
if (acceptableStatus.indexOf(response.status.toString()) >= 0) {
setOnline('online');
setResponse(response.status);
} else {
setOnline('down');
setResponse(response.status)
setResponse(response.status);
}
}
@@ -59,7 +59,13 @@ export default function PingComponent(props: any) {
<Tooltip
radius="lg"
style={{ position: 'absolute', bottom: 20, right: 20 }}
label={isOnline === 'loading' ? 'Loading...' : isOnline === 'online' ? `Online - ${response}` : `Offline - ${response}`}
label={
isOnline === 'loading'
? 'Loading...'
: isOnline === 'online'
? `Online - ${response}`
: `Offline - ${response}`
}
>
<motion.div
animate={{

View File

@@ -1,10 +0,0 @@
import SearchBar from './SearchModule';
export default {
title: 'Search bar',
config: {
searchBar: false,
},
};
export const Default = (args: any) => <SearchBar {...args} />;

View File

@@ -1,4 +1,4 @@
import { Kbd, createStyles, Text, Popover, Autocomplete } from '@mantine/core';
import { Kbd, createStyles, Autocomplete } from '@mantine/core';
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
import { useEffect, useRef, useState } from 'react';
import {
@@ -96,44 +96,32 @@ export default function SearchBar(props: any) {
} else if (isTorrent) {
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
} else {
window.open(`${queryUrl}${values.query}`);
window.open(
`${
queryUrl.includes('%s')
? queryUrl.replace('%s', values.query)
: queryUrl + values.query
}`
);
}
}, 20);
})}
>
<Popover
opened={opened}
position="bottom"
placement="start"
width={260}
withArrow
<Autocomplete
autoFocus
variant="filled"
data={autocompleteData}
icon={icon}
ref={textInput}
rightSectionWidth={90}
rightSection={rightSection}
radius="md"
trapFocus={false}
transition="pop-bottom-right"
onFocusCapture={() => setOpened(true)}
onBlurCapture={() => setOpened(false)}
target={
<Autocomplete
variant="filled"
data={autocompleteData}
icon={icon}
ref={textInput}
rightSectionWidth={90}
rightSection={rightSection}
radius="md"
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
{...props}
{...form.getInputProps('query')}
/>
}
>
<Text>
Tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube
or for a Torrent respectively.
</Text>
</Popover>
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
{...props}
{...form.getInputProps('query')}
/>
</form>
);
}

View File

@@ -1,4 +1,4 @@
import { Group, Space, Title, Tooltip } from '@mantine/core';
import { Group, Space, Title, Tooltip, Skeleton } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import {
@@ -29,7 +29,7 @@ export const WeatherModule: IModule = {
},
location: {
name: 'Current location',
value: '',
value: 'Paris',
},
},
};
@@ -135,7 +135,7 @@ export default function WeatherComponent(props: any) {
const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? '';
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? 'Paris';
const isFahrenheit: boolean =
(config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
@@ -157,7 +157,18 @@ export default function WeatherComponent(props: any) {
});
}, [cityInput]);
if (!weather.current_weather) {
return null;
return (
<>
<Skeleton height={40} width={100} mb="xl" />
<Group noWrap direction="row">
<Skeleton height={50} circle />
<Group>
<Skeleton height={25} width={70} mr="lg" />
<Skeleton height={25} width={70} />
</Group>
</Group>
</>
);
}
function usePerferedUnit(value: number): string {
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;

15
src/middleware.ts Normal file
View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// eslint-disable-next-line consistent-return
export function middleware(request: NextRequest) {
// const cookie = request.cookies.get('password');
// const isPasswordCorrect = cookie === process.env.PASSWORD;
// if (
// !isPasswordCorrect &&
// request.nextUrl.pathname !== '/login' &&
// request.nextUrl.pathname !== '/api/configs/trylogin'
// ) {
// return NextResponse.redirect('/login');
// }
}

View File

@@ -58,7 +58,7 @@ const useStyles = createStyles((theme) => ({
},
}));
export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
const theme = useMantineTheme();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
@@ -70,7 +70,7 @@ export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
);
}
export default function NothingFoundBackground() {
export default function Custom404() {
const { classes } = useStyles();
return (

View File

@@ -1,7 +1,7 @@
import { GetServerSidePropsContext } from 'next';
import { useState } from 'react';
import { AppProps } from 'next/app';
import { getCookie, setCookies } from 'cookies-next';
import { getCookie, setCookie } from 'cookies-next';
import Head from 'next/head';
import { MantineProvider, ColorScheme, ColorSchemeProvider, MantineTheme } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications';
@@ -30,7 +30,7 @@ export default function App(this: any, props: AppProps & { colorScheme: ColorSch
const toggleColorScheme = (value?: ColorScheme) => {
const nextColorScheme = value || (colorScheme === 'dark' ? 'light' : 'dark');
setColorScheme(nextColorScheme);
setCookies('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
setCookie('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
};
useHotkeys([['mod+J', () => toggleColorScheme()]]);

View File

@@ -1,15 +0,0 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest, ev: NextFetchEvent) {
const ok = req.cookies.password === process.env.PASSWORD;
const url = req.nextUrl.clone();
if (
!ok &&
url.pathname !== '/login' &&
process.env.PASSWORD &&
url.pathname !== '/api/configs/tryPassword'
) {
url.pathname = '/login';
}
return NextResponse.rewrite(url);
}

View File

@@ -0,0 +1,60 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Docker from 'dockerode';
const docker = new Docker();
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request
const { id } = req.query as { id: string };
const { action } = req.query;
// Get the action on the request (start, stop, restart)
if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
return res.status(400).json({
statusCode: 400,
message: 'Invalid action',
});
}
if (!id) {
return res.status(400).json({
message: 'Missing ID',
});
}
// Get the container with the ID
const container = docker.getContainer(id);
const startAction = async () => {
switch (action) {
case 'remove':
return container.remove();
case 'start':
return container.start();
case 'stop':
return container.stop();
case 'restart':
return container.restart();
default:
return Promise;
}
};
try {
await startAction();
return res.status(200).json({
statusCode: 200,
message: `Container ${id} ${action}ed`,
});
} catch (err) {
return res.status(500).json(
err,
);
}
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a Put or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Docker from 'dockerode';
async function Get(req: NextApiRequest, res: NextApiResponse) {
try {
const docker = new Docker();
const containers = await docker.listContainers({ all: true });
res.status(200).json(containers);
} catch (err) {
res.status(500).json({ err });
}
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -1,9 +1,24 @@
import axios from 'axios';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { serviceItem } from '../../../tools/types';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
const { id } = req.body;
const { type } = req.query;
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
// Find service with serviceId in config
const service = config.services.find((service) => service.id === id);
if (!service) {
return res.status(500).json({
statusCode: 500,
message: 'Missing service',
});
}
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
const lastMonth = new Date(new Date().setMonth(new Date().getMonth() - 2)).toISOString();
const TypeToUrl: { service: string; url: string }[] = [
@@ -24,8 +39,6 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
url: '/api/v1/calendar',
},
];
const service: serviceItem = req.body;
const { type } = req.query;
if (!type) {
return res.status(400).json({
message: 'Missing required parameter in url: type',
@@ -44,15 +57,15 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
});
}
// Get the origin URL
var { href: origin } = new URL(service.url);
if (origin.endsWith("/")) {
origin = origin.slice(0, -1)
let { href: origin } = new URL(service.url);
if (origin.endsWith('/')) {
origin = origin.slice(0, -1);
}
const pined = `${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`;
const data = await axios.get(
`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`
);
return res.status(200).json(data.data);
return axios
.get(`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`)
.then((response) => res.status(200).json(response.data))
.catch((e) => res.status(500).json(e));
// // Make a request to the URL
// const response = await axios.get(url);
// // Return the response

View File

@@ -2,59 +2,65 @@ import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { Transmission } from '@ctrl/transmission';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the type of service from the request url
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const qBittorrentServices = config.services.filter((service) => service.type === 'qBittorrent');
const delugeServices = config.services.filter((service) => service.type === 'Deluge');
const transmissionServices = config.services.filter((service) => service.type === 'Transmission');
const torrents: NormalizedTorrent[] = [];
const { config }: { config: Config } = req.body;
const qBittorrentService = config.services
.filter((service) => service.type === 'qBittorrent')
.at(0);
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
const transmissionService = config.services
.filter((service) => service.type === 'Transmission')
.at(0);
if (!qBittorrentService && !delugeService && !transmissionService) {
if (!qBittorrentServices && !delugeServices && !transmissionServices) {
return res.status(500).json({
statusCode: 500,
message: 'Missing service',
message: 'Missing services',
});
}
if (qBittorrentService) {
torrents.push(
...(
await new QBittorrent({
baseUrl: qBittorrentService.url,
username: qBittorrentService.username,
password: qBittorrentService.password,
}).getAllData()
).torrents
try {
await Promise.all(
qBittorrentServices.map((service) =>
new QBittorrent({
baseUrl: service.url,
username: service.username,
password: service.password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
}
if (delugeService) {
torrents.push(
...(
await new Deluge({
baseUrl: delugeService.url,
password: 'password' in delugeService ? delugeService.password : '',
}).getAllData()
).torrents
await Promise.all(
delugeServices.map((service) =>
new Deluge({
baseUrl: service.url,
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
}
if (transmissionService) {
torrents.push(
...(
await new Transmission({
baseUrl: transmissionService.url,
username: transmissionService.username,
password: 'password' in transmissionService ? transmissionService.password : '',
}).getAllData()
).torrents
// Map transmissionServices
await Promise.all(
transmissionServices.map((service) =>
new Transmission({
baseUrl: service.url,
username: 'username' in service ? service.username : '',
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
} catch (e: any) {
return res.status(401).json(e);
}
res.status(200).json(torrents);
return res.status(200).json(torrents);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookies } from 'cookies-next';
import { getCookie, setCookie } from 'cookies-next';
import { GetServerSidePropsContext } from 'next';
import { useEffect } from 'react';
import AppShelf from '../components/AppShelf/AppShelf';
@@ -16,7 +16,7 @@ export async function getServerSideProps({
}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> {
let cookie = getCookie('config-name', { req, res });
if (!cookie) {
setCookies('config-name', 'default', {
setCookie('config-name', 'default', {
req,
res,
maxAge: 60 * 60 * 24 * 30,

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { PasswordInput, Anchor, Paper, Title, Text, Container, Group, Button } from '@mantine/core';
import { setCookies } from 'cookies-next';
import { setCookie } from 'cookies-next';
import { useForm } from '@mantine/hooks';
import { showNotification, updateNotification } from '@mantine/notifications';
import axios from 'axios';
import { IconCheck, IconX } from '@tabler/icons';
import { Logo } from '../components/layout/Logo';
// TODO: Add links to the wiki articles about the login process.
export default function AuthenticationTitle() {
@@ -15,22 +16,26 @@ export default function AuthenticationTitle() {
});
return (
<Container
size={420}
size="lg"
style={{
height: '100vh',
display: 'flex',
width: 420,
width: '100%',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Title
align="center"
sx={(theme) => ({ fontFamily: `Greycliff CF, ${theme.fontFamily}`, fontWeight: 900 })}
>
Welcome back!
</Title>
<Group>
<Title
align="center"
sx={(theme) => ({ fontFamily: `Greycliff CF, ${theme.fontFamily}`, fontWeight: 900 })}
>
Welcome back!
</Title>
<Logo withoutText />
</Group>
<Text color="dimmed" size="sm" align="center" mt={5}>
Please enter the{' '}
<Anchor<'a'> href="#" size="sm" onClick={(event) => event.preventDefault()}>
@@ -38,10 +43,17 @@ export default function AuthenticationTitle() {
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md" style={{ width: 420 }}>
<Paper
withBorder
shadow="md"
p={30}
mt={30}
radius="md"
style={{ width: '100%', maxWidth: 420 }}
>
<form
onSubmit={form.onSubmit((values) => {
setCookies('password', values.password, {
setCookie('password', values.password, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
});
@@ -64,8 +76,7 @@ export default function AuthenticationTitle() {
id: 'load-data',
color: 'teal',
title: 'Password correct',
message:
'Notification will close in 2 seconds, you can close this notification now',
message: undefined,
icon: <IconCheck />,
autoClose: 300,
onClose: () => {
@@ -78,8 +89,7 @@ export default function AuthenticationTitle() {
id: 'load-data',
color: 'red',
title: 'Password is wrong, please try again.',
message:
'Notification will close in 2 seconds, you can close this notification now',
message: undefined,
icon: <IconX />,
autoClose: 2000,
});
@@ -93,14 +103,10 @@ export default function AuthenticationTitle() {
label="Password"
placeholder="Your password"
required
autoFocus
mt="md"
{...form.getInputProps('password')}
/>
<Group position="apart" mt="md">
<Anchor<'a'> onClick={(event) => event.preventDefault()} href="#" size="sm">
Forgot password?
</Anchor>
</Group>
<Button fullWidth type="submit" mt="xl">
Sign in
</Button>

57
src/tools/addToHomarr.ts Normal file
View File

@@ -0,0 +1,57 @@
import Dockerode from 'dockerode';
import { Config, MatchingImages, ServiceType, tryMatchPort } from './types';
async function MatchIcon(name: string) {
const res = await fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`
);
return res.ok ? res.url : '/favicon.svg';
}
function tryMatchType(imageName: string): ServiceType {
// Try to find imageName inside MatchingImages
const match = MatchingImages.find(({ image }) => imageName.includes(image));
if (match) {
return match.type;
}
return 'Other';
}
export function tryMatchService(container: Dockerode.ContainerInfo | undefined) {
if (container === undefined) return {};
const name = container.Names[0].substring(1);
const type = tryMatchType(container.Image);
const port = tryMatchPort(type.toLowerCase());
return {
name,
id: container.Id,
type: tryMatchType(container.Image),
url: `localhost${port ? `:${port.value}` : ''}`,
icon: `https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`,
};
}
export default async function addToHomarr(
container: Dockerode.ContainerInfo,
config: Config,
setConfig: (newconfig: Config) => void
) {
setConfig({
...config,
services: [
...config.services,
{
name: container.Names[0].substring(1),
id: container.Id,
type: tryMatchType(container.Image),
url: `localhost:${container.Ports.at(0)?.PublicPort}`,
icon: await MatchIcon(container.Names[0].substring(1)),
},
],
});
}

View File

@@ -14,7 +14,11 @@ export function getConfig(name: string) {
settings: {
searchUrl: 'https://www.google.com/search?q=',
},
modules: {},
modules: {
'Search Bar': {
enabled: true,
},
},
},
},
};

View File

@@ -33,55 +33,134 @@ interface ConfigModule {
}
export const StatusCodes = [
{value: '200', label: '200 - OK', group:'Sucessful responses'},
{value: '204', label: '204 - No Content', group:'Sucessful responses'},
{value: '301', label: '301 - Moved Permanently', group:'Redirection responses'},
{value: '302', label: '302 - Found / Moved Temporarily', group:'Redirection responses'},
{value: '304', label: '304 - Not Modified', group:'Redirection responses'},
{value: '307', label: '307 - Temporary Redirect', group:'Redirection responses'},
{value: '308', label: '308 - Permanent Redirect', group:'Redirection responses'},
{value: '400', label: '400 - Bad Request', group:'Client error responses'},
{value: '401', label: '401 - Unauthorized', group:'Client error responses'},
{value: '403', label: '403 - Forbidden', group:'Client error responses'},
{value: '404', label: '404 - Not Found', group:'Client error responses'},
{value: '408', label: '408 - Request Timeout', group:'Client error responses'},
{value: '410', label: '410 - Gone', group:'Client error responses'},
{value: '429', label: '429 - Too Many Requests', group:'Client error responses'},
{value: '500', label: '500 - Internal Server Error', group:'Server error responses'},
{value: '502', label: '502 - Bad Gateway', group:'Server error responses'},
{value: '503', label: '503 - Service Unavailable', group:'Server error responses'},
{value: '054', label: '504 - Gateway Timeout Error', group:'Server error responses'},
];
{ value: '200', label: '200 - OK', group: 'Sucessful responses' },
{ value: '204', label: '204 - No Content', group: 'Sucessful responses' },
{ value: '301', label: '301 - Moved Permanently', group: 'Redirection responses' },
{ value: '302', label: '302 - Found / Moved Temporarily', group: 'Redirection responses' },
{ value: '304', label: '304 - Not Modified', group: 'Redirection responses' },
{ value: '307', label: '307 - Temporary Redirect', group: 'Redirection responses' },
{ value: '308', label: '308 - Permanent Redirect', group: 'Redirection responses' },
{ value: '400', label: '400 - Bad Request', group: 'Client error responses' },
{ value: '401', label: '401 - Unauthorized', group: 'Client error responses' },
{ value: '403', label: '403 - Forbidden', group: 'Client error responses' },
{ value: '404', label: '404 - Not Found', group: 'Client error responses' },
{ value: '408', label: '408 - Request Timeout', group: 'Client error responses' },
{ value: '410', label: '410 - Gone', group: 'Client error responses' },
{ value: '429', label: '429 - Too Many Requests', group: 'Client error responses' },
{ value: '500', label: '500 - Internal Server Error', group: 'Server error responses' },
{ value: '502', label: '502 - Bad Gateway', group: 'Server error responses' },
{ value: '503', label: '503 - Service Unavailable', group: 'Server error responses' },
{ value: '054', label: '504 - Gateway Timeout Error', group: 'Server error responses' },
];
export const Targets = [
{value: '_blank', label: 'New Tab'},
{value: '_top', label: 'Same Window'}
]
{ value: '_blank', label: 'New Tab' },
{ value: '_top', label: 'Same Window' },
];
export const ServiceTypeList = [
'Other',
'Emby',
'Dash.',
'Deluge',
'Emby',
'Lidarr',
'Plex',
'qBittorrent',
'Radarr',
'Readarr',
'Sonarr',
'qBittorrent',
'Transmission',
];
export type ServiceType =
| 'Other'
| 'Emby'
| 'Dash.'
| 'Deluge'
| 'Emby'
| 'Lidarr'
| 'Plex'
| 'qBittorrent'
| 'Radarr'
| 'Readarr'
| 'Sonarr'
| 'qBittorrent'
| 'Transmission';
export function tryMatchPort(name: string, form?: any) {
// Match name with portmap key
const port = portmap.find((p) => p.name === name.toLowerCase());
if (form && port) {
form.setFieldValue('url', `http://localhost:${port.value}`);
}
return port;
}
export const portmap = [
{ name: 'qbittorrent', value: '8080' },
{ name: 'sonarr', value: '8989' },
{ name: 'radarr', value: '7878' },
{ name: 'lidarr', value: '8686' },
{ name: 'readarr', value: '8787' },
{ name: 'deluge', value: '8112' },
{ name: 'transmission', value: '9091' },
{ name: 'dash.', value: '3001' },
];
export const MatchingImages: {
image: string;
type: ServiceType;
}[] = [
//Official images
{ image: 'mauricenino/dashdot', type: 'Dash.' },
{ image: 'emby/embyserver', type: 'Emby' },
{ image: 'plexinc/pms-docker', type: 'Plex' },
//Lidarr images
{ image: 'hotio/lidarr', type: 'Lidarr' },
{ image: 'ghcr.io/hotio/lidarr', type: 'Lidarr' },
{ image: 'cr.hotio.dev/hotio/lidarr', type: 'Lidarr' },
// Plex
{ image: 'hotio/plex', type: 'Plex' },
{ image: 'ghcr.io/hotio/plex', type: 'Plex' },
{ image: 'cr.hotio.dev/hotio/plex', type: 'Plex' },
// qbittorrent
{ image: 'hotio/qbittorrent', type: 'qBittorrent' },
{ image: 'ghcr.io/hotio/qbittorrent', type: 'qBittorrent' },
{ image: 'cr.hotio.dev/hotio/qbittorrent', type: 'qBittorrent' },
// Radarr
{ image: 'hotio/radarr', type: 'Radarr' },
{ image: 'ghcr.io/hotio/radarr', type: 'Radarr' },
{ image: 'cr.hotio.dev/hotio/radarr', type: 'Radarr' },
// Readarr
{ image: 'hotio/readarr', type: 'Readarr' },
{ image: 'ghcr.io/hotio/readarr', type: 'Readarr' },
{ image: 'cr.hotio.dev/hotio/readarr', type: 'Readarr' },
// Sonarr
{ image: 'hotio/sonarr', type: 'Sonarr' },
{ image: 'ghcr.io/hotio/sonarr', type: 'Sonarr' },
{ image: 'cr.hotio.dev/hotio/sonarr', type: 'Sonarr' },
//LinuxServer images
{ image: 'lscr.io/linuxserver/deluge', type: 'Deluge' },
{ image: 'lscr.io/linuxserver/emby', type: 'Emby' },
{ image: 'lscr.io/linuxserver/lidarr', type: 'Lidarr' },
{ image: 'lscr.io/linuxserver/plex', type: 'Plex' },
{ image: 'lscr.io/linuxserver/qbittorrent', type: 'qBittorrent' },
{ image: 'lscr.io/linuxserver/radarr', type: 'Radarr' },
{ image: 'lscr.io/linuxserver/readarr', type: 'Readarr' },
{ image: 'lscr.io/linuxserver/sonarr', type: 'Sonarr' },
{ image: 'lscr.io/linuxserver/transmission', type: 'Transmission' },
// LinuxServer but on Docker Hub
{ image: 'linuxserver/deluge', type: 'Deluge' },
{ image: 'linuxserver/emby', type: 'Emby' },
{ image: 'linuxserver/lidarr', type: 'Lidarr' },
{ image: 'linuxserver/plex', type: 'Plex' },
{ image: 'linuxserver/qbittorrent', type: 'qBittorrent' },
{ image: 'linuxserver/radarr', type: 'Radarr' },
{ image: 'linuxserver/readarr', type: 'Readarr' },
{ image: 'linuxserver/sonarr', type: 'Sonarr' },
{ image: 'linuxserver/transmission', type: 'Transmission' },
//High usage
{ image: 'markusmcnugen/qbittorrentvpn', type: 'qBittorrent' },
{ image: 'haugene/transmission-openvpn', type: 'Transmission' },
];
export interface serviceItem {
id: string;
name: string;

11450
yarn.lock

File diff suppressed because it is too large Load Diff