diff --git a/.commitlintrc.mjs b/.commitlintrc.mjs index 2337e7e..e3610d9 100644 --- a/.commitlintrc.mjs +++ b/.commitlintrc.mjs @@ -1,4 +1,4 @@ -export default { +const config = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ @@ -19,8 +19,12 @@ export default { 'ui', 'wip', 'publish', + 'docker', + 'WIP', ], ], }, ignores: [(message) => message.includes('WIP'), (message) => message.includes('wip')], }; + +export default config; diff --git a/.env.sample b/.env.sample index 68d3bf0..a2e1c7c 100644 --- a/.env.sample +++ b/.env.sample @@ -23,8 +23,6 @@ CONFIG_PATH=./config SSH_PATH=./ssh SSH_HOST=./ssh_host BORG_REPOSITORY_PATH=./repos -TMP_PATH=./tmp -LOGS_PATH=./logs ## Optional variables section ## diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..136334e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report a bug +title: '' +labels: '' +assignees: '' + +--- + +**BorgWarehouse version :** +**Installation type :** +- [ ] Docker +- [ ] Baremetal (Debian/Ubuntu) +- [ ] Other environment : + +------- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. + +**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/) + is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.** diff --git a/.github/ISSUE_TEMPLATE/i-need-help.md b/.github/ISSUE_TEMPLATE/i-need-help.md new file mode 100644 index 0000000..8aa78e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/i-need-help.md @@ -0,0 +1,21 @@ +--- +name: I need help +about: You need help about installation, usage, or specific cases. +title: '' +labels: help wanted +assignees: '' + +--- + +**BorgWarehouse version :** +**Installation type :** +- [ ] Docker +- [ ] Baremetal (Debian/Ubuntu) +- [ ] Other environment : + +------- + +Describe your problem here. + +**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/) + is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3110836..34a7437 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,18 @@ version: 2 updates: - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: 'docker' + directory: '/' schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/" + interval: 'daily' + # Note: Dependabot uses "npm" ecosystem but automatically detects pnpm-lock.yaml + # Make sure package-lock.json is gitignored to prevent confusion + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "daily" + interval: 'daily' # Maintain dependencies for GitHub Actions # src: https://github.com/marketplace/actions/build-and-push-docker-images#keep-up-to-date-with-github-dependabot - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "daily" + interval: 'daily' diff --git a/.github/workflows/bats.yml b/.github/workflows/bats.yml new file mode 100644 index 0000000..825fd0d --- /dev/null +++ b/.github/workflows/bats.yml @@ -0,0 +1,29 @@ +name: Bats + +permissions: + contents: read + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop +jobs: + bats-test: + name: Run bats tests against shells + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build container & run bats tests + run: | + docker compose -f tests/bats/docker-compose.yml up --abort-on-container-exit --build diff --git a/.github/workflows/docker-image-develop.yml b/.github/workflows/docker-image-develop.yml index 4ce762a..14c33a5 100644 --- a/.github/workflows/docker-image-develop.yml +++ b/.github/workflows/docker-image-develop.yml @@ -1,29 +1,38 @@ name: Build and Push Docker Image for Develop Branch on: - push: - branches: - - 'develop' + push: + branches: + - 'develop' + +permissions: + contents: read jobs: - docker: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946 - tags: borgwarehouse/borgwarehouse:develop + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Update package.json version + run: | + COMMIT=$(git rev-parse --short HEAD) + echo "Current Commit: $COMMIT" + jq '.version = "develop-'$COMMIT'"' package.json > package.tmp.json + mv package.tmp.json package.json + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946 + tags: borgwarehouse/borgwarehouse:develop diff --git a/.github/workflows/docker-image-latest.yml b/.github/workflows/docker-image-latest.yml index f3a4554..7957cb8 100644 --- a/.github/workflows/docker-image-latest.yml +++ b/.github/workflows/docker-image-latest.yml @@ -1,4 +1,6 @@ name: Build and Push Docker Image +permissions: + contents: read on: push: @@ -10,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx diff --git a/.github/workflows/docker-image-release.yml b/.github/workflows/docker-image-release.yml index eabb0d2..01f4eb5 100644 --- a/.github/workflows/docker-image-release.yml +++ b/.github/workflows/docker-image-release.yml @@ -5,12 +5,15 @@ on: types: - published +permissions: + contents: read + jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx diff --git a/.github/workflows/docker-image-test.yml b/.github/workflows/docker-image-test.yml index 448f45c..4d716ad 100644 --- a/.github/workflows/docker-image-test.yml +++ b/.github/workflows/docker-image-test.yml @@ -1,21 +1,24 @@ -name: Test Docker Container Build on Pull Request +name: Test to build docker container on Pull Request + +permissions: + contents: read on: - pull_request: - branches: - - main - - develop + pull_request: + branches: + - main + - develop jobs: - build-container: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Docker Container - run: | - docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} . + build-container: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build BorgWarehouse Container + run: | + docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} . diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 92522ae..8b954af 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -4,19 +4,21 @@ on: - main - develop pull_request: - branches: main + branches: + - main + - develop -name: "Shellcheck" +name: 'Shellcheck' permissions: {} jobs: shellcheck: name: Shellcheck runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v6 + - name: Run ShellCheck uses: ludeeus/action-shellcheck@master env: diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml new file mode 100644 index 0000000..fe9d512 --- /dev/null +++ b/.github/workflows/vitest.yml @@ -0,0 +1,63 @@ +name: Vitest & ESLint CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + test: + name: Run Vitest + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Vitest + run: pnpm run test + + lint: + name: Run ESLint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run ESLint + run: pnpm exec eslint diff --git a/.gitignore b/.gitignore index 2373057..c95738d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,14 @@ typings/ # Optional npm cache directory .npm +# pnpm +.pnpm-store/ +pnpm-debug.log* + +# Lock files (pnpm-lock.yaml is used) +package-lock.json +yarn.lock + # Optional eslint cache .eslintcache @@ -111,4 +119,7 @@ config/repo.json config/users.json # docker files -docker-compose.yml \ No newline at end of file +docker-compose.yml + +# Commit tests docker-compose +!tests/bats/docker-compose.yml \ No newline at end of file diff --git a/.husky/append-icon.sh b/.husky/append-icon.sh index b15ddec..a54594a 100755 --- a/.husky/append-icon.sh +++ b/.husky/append-icon.sh @@ -23,40 +23,43 @@ function checkBreakingChangeInBody() { } function findTypeIcon() { - # get message from 1st param message="$1" - # declare an icons for each authorized enum-type from `.commitlintrc.js` - declare -A icons - icons[build]='πŸ€–' - icons[chore]='🧹' - icons[config]='πŸ”§' - icons[deploy]='πŸš€' - icons[doc]='πŸ“š' - icons[feat]='✨' - icons[fix]='πŸ›' - icons[hotfix]='πŸš‘' - icons[i18n]='πŸ’¬' - icons[publish]='πŸ“¦' - icons[refactor]='⚑' - icons[revert]='βͺ' - icons[test]='βœ…' - icons[ui]='🎨' - icons[wip]='🚧' - icons[WIP]='🚧' + if [[ "$message" =~ ^.*!:\ .* ]]; then + echo "$boomIcon" + return 0 + fi - for type in "${!icons[@]}"; do - # check if message subject contains breaking change pattern - if [[ "$message" =~ ^(.*)(!:){1}(.*)$ ]]; then - echo "$boomIcon" - return 0 - # else find corresponding type icon - elif [[ "$message" == "$type"* ]]; then - echo "${icons[$type]}" - return 0 - fi - done - return 1 + declare -A icons=( + [build]='πŸ€–' + [chore]='🧹' + ["chore(deps)"]='🧹' + [config]='πŸ”§' + [deploy]='πŸš€' + [doc]='πŸ“š' + [feat]='✨' + [fix]='πŸ›' + [hotfix]='πŸš‘' + [i18n]='πŸ’¬' + [publish]='πŸ“¦' + [refactor]='⚑' + [revert]='βͺ' + [test]='βœ…' + [ui]='🎨' + [wip]='🚧' + [WIP]='🚧' + [docker]='🐳' + ) + + commit_type="${message%%:*}" + + icon="${icons[$commit_type]}" + if [[ -n "$icon" ]]; then + echo "$icon" + return 0 + else + return 1 + fi } # extract original message from the first line of file diff --git a/.husky/commit-msg b/.husky/commit-msg index eb9834a..993e036 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,6 +1,3 @@ -#!/bin/bash -. "$(dirname "$0")/_/husky.sh" - # run commit lint npx commitlint --edit "$1" diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 5942463..d48435c 100755 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -1,6 +1,3 @@ -#!/bin/bash -. "$(dirname "$0")/_/husky.sh" - # Check if it's an amend commit if [ "$2" = "commit" ]; then echo "Amendment detected, appending icon..." diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cbcc181 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# Configuration pnpm +auto-install-peers=true +strict-peer-dependencies=false +shamefully-hoist=false + +# Force pnpm usage (prevent npm/yarn) +package-manager=pnpm diff --git a/Components/Repo/QuickCommands/QuickCommands.module.css b/Components/Repo/QuickCommands/QuickCommands.module.css index faba43d..77cc329 100644 --- a/Components/Repo/QuickCommands/QuickCommands.module.css +++ b/Components/Repo/QuickCommands/QuickCommands.module.css @@ -2,7 +2,7 @@ display: flex; align-items: center; align-self: flex-start; - margin: auto 47px auto auto; + margin: auto 25px auto auto; } .icons { @@ -31,7 +31,7 @@ width: 100%; height: 100%; border: 1px solid #6d4aff21; - background-color: #f5f5f5; + background-color: #fafafa; border-radius: 5px; box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset; color: #65748b; @@ -50,6 +50,7 @@ .copyValid { margin: auto 8px auto auto; + padding: 6px 6px; font-size: 0.95rem; color: #6d4aff; animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; @@ -76,7 +77,7 @@ width: 100%; height: 100%; border: 1px solid #6d4aff21; - background-color: #f5f5f5; + background-color: #fafafa; border-radius: 5px; box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset; color: #65748b; diff --git a/Components/Repo/QuickCommands/QuickCommands.js b/Components/Repo/QuickCommands/QuickCommands.tsx similarity index 72% rename from Components/Repo/QuickCommands/QuickCommands.js rename to Components/Repo/QuickCommands/QuickCommands.tsx index d32002e..8fe57b3 100644 --- a/Components/Repo/QuickCommands/QuickCommands.js +++ b/Components/Repo/QuickCommands/QuickCommands.tsx @@ -1,26 +1,30 @@ -//Lib import React from 'react'; import { useState } from 'react'; import classes from './QuickCommands.module.css'; import { IconSettingsAutomation, IconCopy } from '@tabler/icons-react'; -import lanCommandOption from '../../../helpers/functions/lanCommandOption'; +import { lanCommandOption } from '~/helpers/functions'; +import { WizardEnvType } from '~/types/domain/config.types'; -export default function QuickCommands(props) { - ////Vars +type QuickCommandsProps = { + repositoryName: string; + wizardEnv?: WizardEnvType; + lanCommand?: boolean; +}; + +export default function QuickCommands(props: QuickCommandsProps) { const wizardEnv = props.wizardEnv; //Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled. const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.lanCommand); - //State const [isCopied, setIsCopied] = useState(false); - //Functions const handleCopy = async () => { // Asynchronously call copy to clipboard navigator.clipboard - .writeText(`ssh://${wizardEnv.UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.repositoryName}`) + .writeText( + `ssh://${wizardEnv?.UNIX_USER}@${FQDN}${SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./${props.repositoryName}` + ) .then(() => { - // If successful, update the isCopied state value setIsCopied(true); setTimeout(() => { setIsCopied(false); @@ -37,8 +41,8 @@ export default function QuickCommands(props) {
Copied !
) : (
- ssh://{wizardEnv.UNIX_USER}@{FQDN} - {SSH_SERVER_PORT}/./ + ssh://{wizardEnv?.UNIX_USER}@{FQDN} + {SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./ {props.repositoryName}
)} diff --git a/Components/Repo/Repo.js b/Components/Repo/Repo.js deleted file mode 100644 index 1e2312a..0000000 --- a/Components/Repo/Repo.js +++ /dev/null @@ -1,180 +0,0 @@ -//Lib -import { useState } from 'react'; -import classes from './Repo.module.css'; -import { - IconSettings, - IconInfoCircle, - IconChevronDown, - IconChevronUp, - IconBellOff, - IconLockPlus, -} from '@tabler/icons-react'; -import timestampConverter from '../../helpers/functions/timestampConverter'; -import StorageBar from '../UI/StorageBar/StorageBar'; -import QuickCommands from './QuickCommands/QuickCommands'; - -export default function Repo(props) { - //Load displayDetails from LocalStorage - const displayDetailsFromLS = () => { - try { - if (localStorage.getItem('displayDetailsRepo' + props.id) === null) { - localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(true)); - return true; - } else { - return JSON.parse(localStorage.getItem('displayDetailsRepo' + props.id)); - } - } catch (error) { - console.log( - 'LocalStorage error, key', - 'displayDetailsRepo' + props.id, - 'will be removed. Try again.', - 'Error message on this key : ', - error - ); - localStorage.removeItem('displayDetailsRepo' + props.id); - } - }; - - //States - const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS); - - //BUTTON : Display or not repo details for ONE repo - const displayDetailsForOneHandler = (boolean) => { - //Update localStorage - localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(boolean)); - setDisplayDetails(boolean); - }; - - //Status indicator - const statusIndicator = () => { - return props.status ? classes.statusIndicatorGreen : classes.statusIndicatorRed; - }; - - //Alert indicator - const alertIndicator = () => { - if (props.alert === 0) { - return ( -
- -
- ); - } - }; - - const appendOnlyModeIndicator = () => { - if (props.appendOnlyMode) { - return ( -
- -
- ); - } - }; - - return ( - <> - {displayDetails ? ( - <> -
-
-
-
{props.alias}
- {appendOnlyModeIndicator()} - {alertIndicator()} - {props.comment && ( -
- -
{props.comment}
-
- )} - -
- - - - - - - - - - - - - - - - - - - - - - -
RepositoryStorage SizeStorage UsedLast changeIDEdit
{props.repositoryName}{props.storageSize} GB - - -
- {props.lastSave === 0 ? '-' : timestampConverter(props.lastSave)} -
-
#{props.id} -
- props.repoManageEditHandler()} - /> -
-
-
- - ) : ( - <> -
-
-
-
{props.alias}
- {appendOnlyModeIndicator()} - {alertIndicator()} - {props.comment && ( -
- -
{props.comment}
-
- )} -
-
- {props.lastSave === 0 ? null : timestampConverter(props.lastSave)} - #{props.id} -
-
- - )} - {displayDetails ? ( -
- { - displayDetailsForOneHandler(false); - }} - /> -
- ) : ( -
- { - displayDetailsForOneHandler(true); - }} - /> -
- )} - - ); -} diff --git a/Components/Repo/Repo.module.css b/Components/Repo/Repo.module.css index d2f52fe..8311137 100644 --- a/Components/Repo/Repo.module.css +++ b/Components/Repo/Repo.module.css @@ -13,16 +13,39 @@ overflow: visible; /* Need to display comment on hover (which is position : absolute) */ position: relative; + background: #fff; } .closeFlex { display: flex; align-items: center; + justify-content: space-between; + width: 100%; padding: 15px; + gap: 10px; } .RepoClose .lastSave { - padding: 15px; + white-space: nowrap; +} + +.RepoClose .leftGroup { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + gap: 10px; +} + +.RepoClose .alias { + font-weight: bold; + color: #111827; + font-size: 1.05em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; + min-width: 0; } /* REPO OPEN */ @@ -35,7 +58,6 @@ 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); width: auto; - max-height: 200px; margin: 20px 0px 0px 0px; padding: 15px; border-radius: 5px; @@ -43,15 +65,29 @@ overflow: visible; /* Need to display comment on hover (which is position : absolute) */ position: relative; + background: #fff; } .openFlex { - display: flex; - align-items: center; - align-self: flex-start; + display: block; width: 100%; } +.aliasFlex { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; +} +.indicatorsFlex { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + width: 100%; + gap: 15px; +} + .tabInfo { width: 100%; overflow-wrap: break-word; @@ -59,7 +95,7 @@ background: #fff; border-radius: 10px; overflow: hidden; - margin: 25px auto; + margin: 15px auto; table-layout: fixed; } @@ -73,7 +109,7 @@ font-size: 1em; color: #fff; line-height: 1.2; - font-weight: normal; + font-weight: 500; } .tabInfo tbody tr { @@ -88,80 +124,52 @@ } /*STATUS*/ - -.statusIndicatorGreen { - background: rgb(9, 255, 0); +.statusIndicatorGreen, +.statusIndicatorRed { border-radius: 50%; - margin: 10px; - height: 15px; - width: 15px; - box-shadow: 0 0 0 0 rgb(9, 255, 0); - transform: scale(1); - animation: pulseGreen 5s infinite; - animation-delay: 1s; + height: 16px; + width: 16px; + flex-shrink: 0; + animation: pulse 5s infinite; } -@keyframes pulseGreen { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(17, 255, 0, 0.7); - } - - 10% { - transform: scale(1); - box-shadow: 0 0 0 10px rgba(17, 255, 0, 0); - } - - 90% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(17, 255, 0, 0); - } +.statusIndicatorGreen { + background: #00d26a; + box-shadow: 0 0 0 0 rgba(0, 210, 106, 0.7); } .statusIndicatorRed { - background: rgb(255, 0, 0); - border-radius: 50%; - margin: 10px; - height: 15px; - width: 15px; - - box-shadow: 0 0 0 0 rgb(255, 0, 0); - transform: scale(1); - animation: pulseRed 5s infinite; + background: #ff3d3d; + box-shadow: 0 0 0 0 rgba(255, 61, 61, 0.7); animation-delay: 0.5s; } -@keyframes pulseRed { +@keyframes pulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4); } - 10% { transform: scale(1); - box-shadow: 0 0 0 10px rgba(255, 0, 0, 0); + box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); } - 90% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); } } /* Alert icon */ - .alertIcon { display: flex; flex-direction: row; align-items: center; - margin-left: 10px; } .appendOnlyModeIcon { display: flex; flex-direction: row; align-items: center; - margin-left: 10px; } /* GENERAL */ @@ -169,6 +177,13 @@ font-weight: bold; color: #111827; font-size: 1.05em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.RepoOpen .alias { + margin-top: 5px; } .lastSave { @@ -184,7 +199,6 @@ display: flex; flex-direction: row; align-items: center; - margin-left: 10px; } .toolTip { @@ -227,23 +241,69 @@ /* MOBILE */ @media all and (max-width: 1000px) { - .tabInfo { - display: none; + .openFlex, + .tabInfo, + .toolTip, + .comment, + .chevron { + display: none !important; } - .toolTip { - display: none; - } - .comment { - display: none; - } - .lastSave { - display: none; + + .RepoOpen, + .RepoClose { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + justify-content: space-between !important; + max-height: 65px !important; + padding: 15px !important; + margin: 20px 0 0 0 !important; } + .closeFlex { - margin: auto; + display: flex !important; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0 !important; + margin: 0 !important; } - .openFlex { - margin: auto; - width: auto; + + .alias { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .leftGroup { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + gap: 10px; + } + + .rightGroup { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + } + + .lastSave { + display: block !important; + color: #65748b; + white-space: nowrap; + font-size: 0.85em; + flex-shrink: 0; + margin-left: 10px; + } + + .appendOnlyModeIcon, + .alertIcon { + display: flex; + align-items: center; } } diff --git a/Components/Repo/Repo.tsx b/Components/Repo/Repo.tsx new file mode 100644 index 0000000..a659670 --- /dev/null +++ b/Components/Repo/Repo.tsx @@ -0,0 +1,261 @@ +import { useState, useMemo } from 'react'; +import classes from './Repo.module.css'; +import { + IconSettings, + IconInfoCircle, + IconChevronDown, + IconChevronUp, + IconBellOff, + IconLockPlus, +} from '@tabler/icons-react'; +import StorageBar from '../UI/StorageBar/StorageBar'; +import QuickCommands from './QuickCommands/QuickCommands'; +import { Repository, WizardEnvType, Optional } from '~/types'; +import { fromUnixTime, formatDistanceStrict } from 'date-fns'; +import useMedia from 'use-media'; + +type RepoProps = Omit & { + repoManageEditHandler: () => void; + wizardEnv: Optional; +}; + +export default function Repo(props: RepoProps) { + const isMobile = useMedia({ maxWidth: 1000 }); + + const currentDate = useMemo(() => new Date(), []); + + //Load displayDetails from LocalStorage + const displayDetailsFromLS = (): boolean => { + const key = `displayDetailsRepo${props.id}`; + + try { + const storedValue = localStorage.getItem(key); + + if (storedValue === null) { + const defaultValue = true; + localStorage.setItem(key, JSON.stringify(defaultValue)); + return defaultValue; + } + + const parsedValue = JSON.parse(storedValue); + if (typeof parsedValue === 'boolean') { + return parsedValue; + } + + localStorage.removeItem(key); + return true; + } catch (error) { + localStorage.removeItem(key); + return true; + } + }; + + //States + const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS); + + //BUTTON : Display or not repo details for ONE repo + const displayDetailsForOneHandler = (boolean: boolean) => { + //Update localStorage + localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(boolean)); + setDisplayDetails(boolean); + }; + + //Status indicator + const statusIndicator = () => { + return props.status ? classes.statusIndicatorGreen : classes.statusIndicatorRed; + }; + + //Alert indicator + const alertIndicator = () => { + if (props.alert === 0) { + return ( +
+ +
+ ); + } + }; + + const appendOnlyModeIndicator = () => { + if (props.appendOnlyMode) { + return ( +
+ +
+ ); + } + }; + + const mobileView = () => { + return ( + <> +
+
+
+
+
{props.alias}
+
+ {appendOnlyModeIndicator()} + {alertIndicator()} + {props.comment && ( +
+ +
{props.comment}
+
+ )} + +
+ + {props.lastSave === 0 + ? '-' + : formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, { + addSuffix: true, + })} + +
+
+
+ + ); + }; + + if (isMobile) { + return mobileView(); + } else { + return ( + <> + {displayDetails ? ( + <> +
+
+
+ {props.comment && ( +
+ +
{props.comment}
+
+ )} + {appendOnlyModeIndicator()} + {alertIndicator()} + +
+
+
{props.alias}
+
+ + + + + + + + + + + + + + + + + + + + +
RepositoryStorage SizeStorage UsedLast changeEdit
{props.repositoryName}{props.storageSize} GB + + +
+ {props.lastSave === 0 + ? '-' + : formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, { + addSuffix: true, + })} +
+
+
+ props.repoManageEditHandler()} + /> +
+
+
+ + ) : ( + <> +
+
+
+
+
{props.alias}
+
+ {appendOnlyModeIndicator()} + {alertIndicator()} + {props.comment && ( +
+ +
{props.comment}
+
+ )} + +
+ + {props.lastSave === 0 + ? '-' + : formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, { + addSuffix: true, + })} + +
+
+
+ + )} + {displayDetails ? ( +
+ { + displayDetailsForOneHandler(false); + }} + /> +
+ ) : ( +
+ { + displayDetailsForOneHandler(true); + }} + /> +
+ )} + + ); + } +} diff --git a/Components/UI/CopyButton/CopyButton.js b/Components/UI/CopyButton/CopyButton.tsx similarity index 77% rename from Components/UI/CopyButton/CopyButton.js rename to Components/UI/CopyButton/CopyButton.tsx index 6de1f00..1258f39 100644 --- a/Components/UI/CopyButton/CopyButton.js +++ b/Components/UI/CopyButton/CopyButton.tsx @@ -1,14 +1,19 @@ -//Lib import classes from './CopyButton.module.css'; -import { useState } from 'react'; +import { useState, ReactNode } from 'react'; import { IconChecks, IconCopy } from '@tabler/icons-react'; -export default function CopyButton(props) { - //State +type CopyButtonProps = { + dataToCopy: string; + children?: ReactNode; + displayIconConfirmation?: boolean; + size?: number; + stroke?: number; +}; + +export default function CopyButton(props: CopyButtonProps) { const [isCopied, setIsCopied] = useState(false); - //Function - const handleCopy = async (data) => { + const handleCopy = async (data: string) => { navigator.clipboard .writeText(data) .then(() => { diff --git a/Components/UI/Error/Error.js b/Components/UI/Error/Error.tsx similarity index 55% rename from Components/UI/Error/Error.js rename to Components/UI/Error/Error.tsx index f677994..d0c4da8 100644 --- a/Components/UI/Error/Error.js +++ b/Components/UI/Error/Error.tsx @@ -1,6 +1,9 @@ -//Lib import classes from './Error.module.css'; -export default function Error(props) { +type ErrorProps = { + message: string; +}; + +export default function Error(props: ErrorProps) { return
{props.message}
; } diff --git a/Components/UI/Info/Info.js b/Components/UI/Info/Info.tsx similarity index 54% rename from Components/UI/Info/Info.js rename to Components/UI/Info/Info.tsx index 5597ef9..8e2f729 100644 --- a/Components/UI/Info/Info.js +++ b/Components/UI/Info/Info.tsx @@ -1,7 +1,13 @@ -//Lib +import { ReactNode } from 'react'; import classes from './Info.module.css'; -export default function Info(props) { +type InfoProps = { + message: string; + color?: string; + children?: ReactNode; +}; + +export default function Info(props: InfoProps) { return (
{props.message} diff --git a/Components/UI/Layout/Footer/Footer.js b/Components/UI/Layout/Footer/Footer.tsx similarity index 87% rename from Components/UI/Layout/Footer/Footer.js rename to Components/UI/Layout/Footer/Footer.tsx index 0e16c54..58cd034 100644 --- a/Components/UI/Layout/Footer/Footer.js +++ b/Components/UI/Layout/Footer/Footer.tsx @@ -1,6 +1,5 @@ -//Lib import classes from './Footer.module.css'; -import packageInfo from '../../../../package.json'; +import packageInfo from '~/package.json'; function Footer() { return ( diff --git a/Components/UI/Layout/Header/Header.module.css b/Components/UI/Layout/Header/Header.module.css index d90271d..2156d01 100644 --- a/Components/UI/Layout/Header/Header.module.css +++ b/Components/UI/Layout/Header/Header.module.css @@ -27,5 +27,5 @@ font-weight: bold; color: #6d4aff; text-shadow: #6d4aff 0px 0px 18px; - margin-left: 20px; + margin-left: 70px; } diff --git a/Components/UI/Layout/Header/Header.js b/Components/UI/Layout/Header/Header.tsx similarity index 50% rename from Components/UI/Layout/Header/Header.js rename to Components/UI/Layout/Header/Header.tsx index d96a71d..1067f6e 100644 --- a/Components/UI/Layout/Header/Header.js +++ b/Components/UI/Layout/Header/Header.tsx @@ -1,14 +1,21 @@ -//Lib +import Image from 'next/image'; import classes from './Header.module.css'; - -//Components import Nav from './Nav/Nav'; function Header() { return (
-
BorgWarehouse
+
+ BorgWarehouse +
diff --git a/Components/WizardSteps/WizardStep3/WizardStep3.js b/Components/WizardSteps/WizardStep3/WizardStep3.tsx similarity index 84% rename from Components/WizardSteps/WizardStep3/WizardStep3.js rename to Components/WizardSteps/WizardStep3/WizardStep3.tsx index 6c8706f..1b065f1 100644 --- a/Components/WizardSteps/WizardStep3/WizardStep3.js +++ b/Components/WizardSteps/WizardStep3/WizardStep3.tsx @@ -1,16 +1,15 @@ -//Lib import React from 'react'; import classes from '../WizardStep1/WizardStep1.module.css'; import { IconChecks, IconPlayerPlay } from '@tabler/icons-react'; import CopyButton from '../../UI/CopyButton/CopyButton'; -import lanCommandOption from '../../../helpers/functions/lanCommandOption'; +import { WizardStepProps } from '~/types'; +import { lanCommandOption } from '~/helpers/functions'; -function WizardStep3(props) { - ////Vars +function WizardStep3(props: WizardStepProps) { const wizardEnv = props.wizardEnv; - const UNIX_USER = wizardEnv.UNIX_USER; + const UNIX_USER = wizardEnv?.UNIX_USER; //Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled. - const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand); + const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand); return (
@@ -31,11 +30,11 @@ function WizardStep3(props) { borg create ssh:// {UNIX_USER}@{FQDN} {SSH_SERVER_PORT}/./ - {props.selectedOption.repositoryName} + {props.selectedRepo?.repositoryName} ::archive1 /your/pathToBackup
@@ -70,10 +69,10 @@ function WizardStep3(props) { borg check -v --progress ssh:// {UNIX_USER}@{FQDN} {SSH_SERVER_PORT}/./ - {props.selectedOption.repositoryName} + {props.selectedRepo?.repositoryName}
  • List the remote archives with :
  • @@ -88,10 +87,10 @@ function WizardStep3(props) { borg list ssh:// {UNIX_USER}@{FQDN} {SSH_SERVER_PORT}/./ - {props.selectedOption.repositoryName} + {props.selectedRepo?.repositoryName}
  • Download a remote archive with the following command :
  • @@ -103,14 +102,14 @@ function WizardStep3(props) { }} >
    - borg export-tar --tar-filter="gzip -9" ssh:// + borg export-tar --tar-filter="gzip -9" ssh:// {UNIX_USER}@{FQDN} {SSH_SERVER_PORT}/./ - {props.selectedOption.repositoryName} + {props.selectedRepo?.repositoryName} ::archive1 archive1.tar.gz
  • Mount an archive to compare or backup some files without download all the archive :
  • @@ -125,11 +124,11 @@ function WizardStep3(props) { borg mount ssh:// {UNIX_USER}@{FQDN} {SSH_SERVER_PORT}/./ - {props.selectedOption.repositoryName} + {props.selectedRepo?.repositoryName} ::archive1 /tmp/yourMountPoint
    diff --git a/Components/WizardSteps/WizardStep4/WizardStep4.js b/Components/WizardSteps/WizardStep4/WizardStep4.tsx similarity index 81% rename from Components/WizardSteps/WizardStep4/WizardStep4.js rename to Components/WizardSteps/WizardStep4/WizardStep4.tsx index e989798..17d355a 100644 --- a/Components/WizardSteps/WizardStep4/WizardStep4.js +++ b/Components/WizardSteps/WizardStep4/WizardStep4.tsx @@ -1,18 +1,17 @@ -//Lib import React from 'react'; import classes from '../WizardStep1/WizardStep1.module.css'; import { IconWand } from '@tabler/icons-react'; import CopyButton from '../../UI/CopyButton/CopyButton'; -import lanCommandOption from '../../../helpers/functions/lanCommandOption'; +import { WizardStepProps } from '~/types'; +import { lanCommandOption } from '~/helpers/functions'; -function WizardStep4(props) { - ////Vars +function WizardStep4(props: WizardStepProps) { const wizardEnv = props.wizardEnv; - const UNIX_USER = wizardEnv.UNIX_USER; + const UNIX_USER = wizardEnv?.UNIX_USER; //Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled. - const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand); + const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand); - const configBorgmatic = `location: + const configBorgmatic = ` # List of source directories to backup. source_directories: - /your-repo-to-backup @@ -20,24 +19,21 @@ function WizardStep4(props) { repositories: # Paths of local or remote repositories to backup to. - - ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName} + - path: ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName} -storage: - archive_name_format: '{FQDN}-documents-{now}' - encryption_passphrase: "YOUR PASSPHRASE" +archive_name_format: '{FQDN}-documents-{now}' +encryption_passphrase: "YOUR PASSPHRASE" -retention: - # Retention policy for how many backups to keep. - keep_daily: 7 - keep_weekly: 4 - keep_monthly: 6 +# Retention policy for how many backups to keep. +keep_daily: 7 +keep_weekly: 4 +keep_monthly: 6 -consistency: - # List of checks to run to validate your backups. - checks: - - name: repository - - name: archives - frequency: 2 weeks +# List of checks to run to validate your backups. +checks: + - name: repository + - name: archives + frequency: 2 weeks #hooks: # Custom preparation scripts to run. diff --git a/Components/WizardSteps/WizardStepBar/WizardStepBar.js b/Components/WizardSteps/WizardStepBar/WizardStepBar.tsx similarity index 90% rename from Components/WizardSteps/WizardStepBar/WizardStepBar.js rename to Components/WizardSteps/WizardStepBar/WizardStepBar.tsx index 757cbe5..a6adb7c 100644 --- a/Components/WizardSteps/WizardStepBar/WizardStepBar.js +++ b/Components/WizardSteps/WizardStepBar/WizardStepBar.tsx @@ -1,12 +1,17 @@ -//Lib import React from 'react'; import classes from './WizardStepBar.module.css'; import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; -function WizardStepBar(props) { - ////Functions +type WizardStepBarProps = { + step: number; + setStep: (step: number) => void; + previousStepHandler: () => void; + nextStepHandler: () => void; +}; + +function WizardStepBar(props: WizardStepBarProps) { //Color onClick on a step - const colorHandler = (step) => { + const colorHandler = (step: number) => { if (step <= props.step) { return classes.active; } else { diff --git a/Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.js b/Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.tsx similarity index 70% rename from Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.js rename to Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.tsx index f499767..3a9bd86 100644 --- a/Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.js +++ b/Containers/Monitoring/StorageUsedChartBar/StorageUsedChartBar.tsx @@ -1,4 +1,3 @@ -//Lib import { Chart as ChartJS, CategoryScale, @@ -10,16 +9,15 @@ import { } from 'chart.js'; import { Bar } from 'react-chartjs-2'; import { useState, useEffect } from 'react'; +import { Repository, Optional } from '~/types'; export default function StorageUsedChartBar() { - //States - const [data, setData] = useState([]); + const [data, setData] = useState>>(); - //LifeCycle useEffect(() => { const dataFetch = async () => { try { - const response = await fetch('/api/repo', { + const response = await fetch('/api/v1/repositories', { method: 'GET', headers: { 'Content-type': 'application/json', @@ -41,10 +39,10 @@ export default function StorageUsedChartBar() { responsive: true, plugins: { legend: { - position: 'bottom', + position: 'bottom' as const, }, title: { - position: 'bottom', + position: 'bottom' as const, display: true, text: 'Storage used for each repository', }, @@ -55,7 +53,7 @@ export default function StorageUsedChartBar() { min: 0, ticks: { // Include a dollar sign in the ticks - callback: function (value) { + callback: function (value: number | string) { return value + '%'; }, stepSize: 10, @@ -64,16 +62,16 @@ export default function StorageUsedChartBar() { }, }; - const labels = data.map((repo) => repo.alias); + const labels = data?.map((repo) => repo.alias); const dataChart = { labels, datasets: [ { label: 'Storage used (%)', - //storageUsed is in octet, storageSize is in GB. Round to 1 decimal for %. - data: data.map((repo) => - (((repo.storageUsed / 1000000) * 100) / repo.storageSize).toFixed(1) + //storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %. + data: data?.map((repo) => + (((repo.storageUsed / 1024 ** 2) * 100) / repo.storageSize).toFixed(1) ), backgroundColor: '#704dff', }, diff --git a/Containers/RepoList/RepoList.js b/Containers/RepoList/RepoList.js deleted file mode 100644 index 809bd9a..0000000 --- a/Containers/RepoList/RepoList.js +++ /dev/null @@ -1,159 +0,0 @@ -//Lib -import classes from './RepoList.module.css'; -import { useState, useEffect } from 'react'; -import { IconPlus } from '@tabler/icons-react'; -import { useRouter } from 'next/router'; -import Link from 'next/link'; -import useSWR, { useSWRConfig } from 'swr'; -import { ToastContainer, toast } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; - -//Composants -import Repo from '../../Components/Repo/Repo'; -import RepoManage from '../RepoManage/RepoManage'; -import ShimmerRepoList from '../../Components/UI/ShimmerRepoList/ShimmerRepoList'; - -export default function RepoList() { - ////Var - const router = useRouter(); - const { mutate } = useSWRConfig(); - const toastOptions = { - position: 'top-right', - autoClose: 8000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - }; - - ////Datas - //Write a fetcher function to wrap the native fetch function and return the result of a call to url in json format - const fetcher = async (url) => await fetch(url).then((res) => res.json()); - const { data, error } = useSWR('/api/repo', fetcher); - - ////LifeCycle - //Component did mount - useEffect(() => { - //If the route is home/manage-repo/add, open the RepoAdd box. - if (router.pathname === '/manage-repo/add') { - setDisplayRepoAdd(!displayRepoAdd); - } - //If the route is home/manage-repo/edit, open the RepoAdd box. - if (router.pathname.startsWith('/manage-repo/edit')) { - setDisplayRepoEdit(!displayRepoEdit); - } - //Fetch wizardEnv to hydrate Repo components - const fetchWizardEnv = async () => { - try { - const response = await fetch('/api/account/getWizardEnv', { - method: 'GET', - headers: { - 'Content-type': 'application/json', - }, - }); - setWizardEnv((await response.json()).wizardEnv); - } catch (error) { - console.log('Fetching datas error'); - } - }; - fetchWizardEnv(); - }, []); - - ////States - const [displayRepoAdd, setDisplayRepoAdd] = useState(false); - const [displayRepoEdit, setDisplayRepoEdit] = useState(false); - const [wizardEnv, setWizardEnv] = useState({}); - - ////Functions - - //Firstly, check the availability of data and condition it. - if (!data) { - //Force mutate after login (force a API GET on /api/repo to load repoList) - mutate('/api/repo'); - return ; - } - if (error) { - toast.error('An error has occurred.', toastOptions); - return ; - } - if (data.status == 500) { - toast.error('API Error !', toastOptions); - return ; - } - - //BUTTON : Display RepoManage component box for ADD - const manageRepoAddHandler = () => { - router.replace('/manage-repo/add'); - }; - - //BUTTON : Display RepoManage component box for EDIT - const repoManageEditHandler = (id) => { - router.replace('/manage-repo/edit/' + id); - }; - - //BUTTON : Close RepoManage component box (when cross is clicked) - const closeRepoManageBoxHandler = () => { - router.replace('/'); - }; - - // UI EFFECT : Display blur when display add repo modale - const displayBlur = () => { - if (displayRepoAdd || displayRepoEdit) { - return classes.containerBlur; - } else { - return classes.container; - } - }; - - //Dynamic list of repositories (with a map of Repo components) - const renderRepoList = data.repoList.map((repo, index) => { - return ( - <> - repoManageEditHandler(repo.id)} - wizardEnv={wizardEnv} - > - - ); - }); - - return ( - <> -
    -
    - - - Add a repository - -
    -
    -
    {renderRepoList}
    -
    -
    - {displayRepoAdd ? ( - - ) : null} - {displayRepoEdit ? ( - - ) : null} - - ); -} diff --git a/Containers/RepoList/RepoList.module.css b/Containers/RepoList/RepoList.module.css index 11ff66b..087d90b 100644 --- a/Containers/RepoList/RepoList.module.css +++ b/Containers/RepoList/RepoList.module.css @@ -86,7 +86,6 @@ flex-direction: column; width: 90%; margin: 5px auto; - padding: 15px; } .unfoldButton { @@ -123,3 +122,77 @@ display: none; } } + +/* Toolbar */ + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + width: 90%; + margin: 20px auto 10px; + flex-wrap: wrap; + gap: 10px; +} + +.searchInput { + padding: 10px 15px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 14px; + width: 100%; + max-width: 300px; +} + +.sortIcons { + display: flex; + gap: 10px; + align-items: center; +} + +.icon { + cursor: pointer; + color: #a6a6b8; + transition: transform 0.2s ease; +} + +.icon:hover { + transform: scale(1.1); + color: #6d4aff; +} + +.iconActive { + color: #6d4aff; + transform: scale(1.2); +} + +.searchContainer { + position: relative; + display: flex; + align-items: center; + width: 100%; + max-width: 300px; +} + +.searchInput { + width: 100%; + padding: 8px 32px 8px 12px; + border-radius: 8px; + border: 1px solid #ccc; + font-size: 14px; + outline: none; + background-color: white; +} + +.clearButton { + position: absolute; + right: 8px; + background: transparent; + border: none; + cursor: pointer; + color: #999; +} + +.clearButton:hover { + color: #333; +} diff --git a/Containers/RepoList/RepoList.tsx b/Containers/RepoList/RepoList.tsx new file mode 100644 index 0000000..d9160f0 --- /dev/null +++ b/Containers/RepoList/RepoList.tsx @@ -0,0 +1,279 @@ +import classes from './RepoList.module.css'; +import React, { useState, useEffect } from 'react'; +import { + IconPlus, + IconSortAscendingLetters, + IconSortDescendingLetters, + IconSortAscending2, + IconSortDescending2, + IconDatabase, + IconX, + IconClock, + IconCalendarUp, + IconCalendarDown, + IconSortAscendingSmallBig, + IconSortDescendingSmallBig, + IconSortDescending2Filled, +} from '@tabler/icons-react'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; +import useSWR, { useSWRConfig } from 'swr'; +import { ToastContainer, ToastOptions, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +import Repo from '~/Components/Repo/Repo'; +import RepoManage from '../RepoManage/RepoManage'; +import ShimmerRepoList from '~/Components/UI/ShimmerRepoList/ShimmerRepoList'; +import { Repository, WizardEnvType, Optional } from '~/types'; + +type SortOption = + | 'alias-asc' + | 'alias-desc' + | 'status-true' + | 'status-false' + | 'storage-used-asc' + | 'storage-used-desc' + | 'last-save-asc' + | 'last-save-desc'; + +export default function RepoList() { + const router = useRouter(); + const { mutate } = useSWRConfig(); + const [displayRepoAdd, setDisplayRepoAdd] = useState(false); + const [displayRepoEdit, setDisplayRepoEdit] = useState(false); + const [wizardEnv, setWizardEnv] = useState>(); + + const [sortOption, setSortOption] = useState(() => { + const savedSort = localStorage.getItem('repoSort'); + return (savedSort as SortOption) || 'alias-asc'; + }); + + const [searchQuery, setSearchQuery] = useState(() => { + const savedSearch = localStorage.getItem('repoSearch'); + return savedSearch || ''; + }); + + const toastOptions: ToastOptions = { + position: 'top-right', + autoClose: 8000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + }; + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setDisplayRepoAdd(router.pathname === '/manage-repo/add'); + setDisplayRepoEdit(router.pathname.startsWith('/manage-repo/edit')); + + const fetchWizardEnv = async () => { + try { + const response = await fetch('/api/v1/account/wizard-env'); + const data: WizardEnvType = await response.json(); + setWizardEnv(data); + } catch (error) { + console.log('Fetching wizard env error'); + } + }; + fetchWizardEnv(); + }, [router.pathname]); + + const fetcher = async (url: string) => await fetch(url).then((res) => res.json()); + const { data, error } = useSWR('/api/v1/repositories', fetcher); + + if (!data) { + mutate('/api/v1/repositories'); + return ; + } + + if (error || data.status == 500) { + toast.error('Error loading repositories.', toastOptions); + return ; + } + + const handleSortChange = (option: SortOption) => { + setSortOption(option); + localStorage.setItem('repoSort', option); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + const query = e.target.value; + setSearchQuery(query); + localStorage.setItem('repoSearch', query); + }; + + const getSortedRepoList = () => { + let repoList = [...data.repoList]; + + // Filter + if (searchQuery) { + repoList = repoList.filter((repo) => + `${repo.alias} ${repo.comment} ${repo.repositoryName}` + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + } + + // Sort + switch (sortOption) { + case 'alias-asc': + return repoList.sort((a, b) => a.alias.localeCompare(b.alias)); + case 'alias-desc': + return repoList.sort((a, b) => b.alias.localeCompare(a.alias)); + case 'status-true': + return repoList.sort((a, b) => Number(b.status) - Number(a.status)); + case 'status-false': + return repoList.sort((a, b) => Number(a.status) - Number(b.status)); + case 'storage-used-asc': + return repoList.sort((a, b) => { + const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0; + const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0; + return aRatio - bRatio; + }); + case 'storage-used-desc': + return repoList.sort((a, b) => { + const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0; + const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0; + return bRatio - aRatio; + }); + case 'last-save-asc': + return repoList.sort((a, b) => { + const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0; + const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0; + return aDate - bDate; + }); + case 'last-save-desc': + return repoList.sort((a, b) => { + const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0; + const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0; + return bDate - aDate; + }); + default: + return repoList; + } + }; + + const manageRepoAddHandler = () => router.replace('/manage-repo/add'); + const manageRepoEditHandler = (id: number) => router.replace('/manage-repo/edit/' + id); + const closeRepoManageBoxHandler = () => router.replace('/'); + const displayBlur = () => + displayRepoAdd || displayRepoEdit ? classes.containerBlur : classes.container; + + const renderRepoList = getSortedRepoList().map((repo: Repository) => ( + manageRepoEditHandler(repo.id)} + wizardEnv={wizardEnv} + /> + )); + + return ( + <> +
    +
    + + + Add a repository + +
    + +
    +
    + + {searchQuery && ( + + )} +
    + +
    + handleSortChange('alias-asc')} + title='Alias A-Z' + /> + handleSortChange('alias-desc')} + title='Alias Z-A' + /> + handleSortChange('status-true')} + title='Status OK β†’ KO' + /> + handleSortChange('status-false')} + title='Status KO β†’ OK' + /> + handleSortChange('last-save-desc')} + title='Last save (recent β†’ old)' + /> + handleSortChange('last-save-asc')} + title='Last save (old β†’ recent)' + /> + handleSortChange('storage-used-asc')} + title='Storage usage % low β†’ high' + /> + handleSortChange('storage-used-desc')} + title='Storage usage % high β†’ low' + /> +
    +
    + +
    +
    {renderRepoList}
    +
    +
    + + {displayRepoAdd && ( + + )} + {displayRepoEdit && ( + + )} + + ); +} diff --git a/Containers/RepoManage/RepoManage.module.css b/Containers/RepoManage/RepoManage.module.css index 94e4348..8c55d5c 100644 --- a/Containers/RepoManage/RepoManage.module.css +++ b/Containers/RepoManage/RepoManage.module.css @@ -11,7 +11,7 @@ .modale { position: fixed; top: 10%; - width: 1000px; + width: 800px; height: auto; max-width: 75%; max-height: 85%; @@ -24,6 +24,11 @@ animation: append-animate 0.3s linear; } +.modale h2 { + margin-top: 0; + color: #374151; +} + @keyframes append-animate { from { transform: scale(0); @@ -47,89 +52,98 @@ .repoManageForm { margin: auto; - width: 80%; - padding: 15px 30px 30px 30px; - border-radius: 5px; - text-align: left; + width: 100%; + max-width: 600px; + border-radius: 8px; + background-color: #ffffff; + font-family: Inter, sans-serif; + color: #1f2937; } .formWrapper { text-align: center; - margin: auto; width: 100%; - height: auto; - color: #494b7a; + margin: 0 auto; + color: inherit; } .repoManageForm label { display: block; - margin-bottom: 8px; - text-align: center; - margin-top: 20px; - color: #494b7a; + margin-top: 0.9rem; + margin-bottom: 0.5rem; + font-weight: 600; + font-size: 0.95rem; + color: #374151; + text-align: left; } .repoManageForm input, .repoManageForm textarea, .repoManageForm select { - border: 1px solid #6d4aff21; - font-size: 16px; - height: auto; - margin: 0; - margin-bottom: 0px; - outline: 0; - padding: 15px; width: 100%; - background-color: #f5f5f5; - border-radius: 5px; - /* color: #1b1340; */ - color: #494b7a; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.9rem; + background-color: #f9fafb; + color: #111827; + font-family: Inter; } .repoManageForm textarea { resize: vertical; + min-height: 80px; } -.repoManageForm textarea:focus, .repoManageForm input:focus, +.repoManageForm textarea:focus, .repoManageForm select:focus { - outline: 1px solid #6d4aff; - box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605); + border-color: #6d4aff; + background-color: #ffffff; + outline: none; + box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.3); } .repoManageForm .invalid { - background: #f3c7c7; - border: 1px solid #e45454; - outline: 1px solid #ff4a4a; + background-color: #fef2f2; + border-color: #ef4444; } .repoManageForm .invalid:focus { - background: #f3c7c7; - border: 1px solid #e45454; - outline: 1px solid #ff4a4a; - box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3); } .repoManageForm button { display: block; - margin: 15px auto; + margin: 2rem auto 0 auto; + background-color: #6d4aff; + color: #ffffff; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; } + .repoManageForm button:hover { - display: block; - margin: 15px auto; + background-color: #5c3dff; } .errorMessage { - color: red; - display: block; - margin-top: 3px; + color: #dc2626; + font-size: 0.875rem; + margin-top: 0.3rem; } .optionCommandWrapper { display: flex; - margin-top: 20px; - color: #494b7a; + gap: 0.5rem; + align-items: center; + margin-top: 1.5rem; + font-size: 0.95rem; + color: #374151; } .optionCommandWrapper label { @@ -137,15 +151,33 @@ } .optionCommandWrapper input[type='checkbox'] { - width: auto; - margin-right: 8px; + width: 18px; + height: 18px; + accent-color: #6d4aff; cursor: pointer; - accent-color: #6d4aff; } + .optionCommandWrapper input[type='checkbox']:focus { - outline: 0; - box-shadow: none; - accent-color: #6d4aff; + outline: none; + box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.4); +} + +.selectAlert { + max-width: 160px; +} +.selectAlertWrapper label { + margin: 0; +} + +.selectAlertWrapper { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin-top: 1.5rem; + gap: 0.5rem; + font-size: 0.95rem; + color: #374151; } /* DELETE DIALOG */ @@ -254,6 +286,7 @@ } .littleDeleteButton { + margin-top: 10px; border: none; font-weight: 300; color: red; @@ -261,8 +294,3 @@ background: none; cursor: pointer; } - -.selectAlert { - margin: auto auto 35px auto; - max-width: 160px; -} diff --git a/Containers/RepoManage/RepoManage.js b/Containers/RepoManage/RepoManage.tsx similarity index 52% rename from Containers/RepoManage/RepoManage.js rename to Containers/RepoManage/RepoManage.tsx index a758efa..b37f0cd 100644 --- a/Containers/RepoManage/RepoManage.js +++ b/Containers/RepoManage/RepoManage.tsx @@ -1,29 +1,46 @@ -//Lib -import classes from './RepoManage.module.css'; -import { IconAlertCircle, IconX } from '@tabler/icons-react'; -import { useState } from 'react'; -import { useRouter } from 'next/router'; -import { toast } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; -import { useForm, Controller } from 'react-hook-form'; -import { SpinnerDotted } from 'spinners-react'; -import Select from 'react-select'; +import { IconAlertCircle, IconExternalLink, IconX } from '@tabler/icons-react'; import Link from 'next/link'; -import { IconExternalLink } from '@tabler/icons-react'; -import { alertOptions } from '../../domain/constants'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import Select from 'react-select'; +import { toast, ToastOptions } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { useLoader } from '~/contexts/LoaderContext'; +import { alertOptions, Optional, Repository } from '~/types'; +import classes from './RepoManage.module.css'; -export default function RepoManage(props) { - ////Var - let targetRepo; +type RepoManageProps = { + mode: 'add' | 'edit'; + repoList: Optional>; + closeHandler: () => void; +}; + +type DataForm = { + alias: string; + storageSize: string; + sshkey: string; + comment: string; + alert: { value: Optional; label: string }; + lanCommand: boolean; + appendOnlyMode: boolean; +}; + +export default function RepoManage(props: RepoManageProps) { const router = useRouter(); + const targetRepo = + props.mode === 'edit' && router.query.slug + ? props.repoList?.find((repo) => repo.id.toString() === router.query.slug) + : undefined; + const { register, handleSubmit, control, formState: { errors, isSubmitting, isValid }, - } = useForm({ mode: 'onChange' }); + } = useForm({ mode: 'onChange' }); - const toastOptions = { + const toastOptions: ToastOptions = { position: 'top-right', autoClose: 5000, hideProgressBar: false, @@ -33,108 +50,113 @@ export default function RepoManage(props) { progress: undefined, }; - ////State const [deleteDialog, setDeleteDialog] = useState(false); const [isLoading, setIsLoading] = useState(false); + const { start, stop } = useLoader(); - ////Functions //router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484). //If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering. - if (!router.query.slug && props.mode == 'edit') { - return ; - } else if (props.mode == 'edit') { - for (let element in props.repoList) { - if (props.repoList[element].id == router.query.slug) { - targetRepo = props.repoList[element]; - } - } - //If the ID does not exist > 404 - if (!targetRepo) { + if (props.mode === 'edit') { + if (!router.query.slug) { + start(); + return; + } else if (!targetRepo) { + stop(); router.push('/404'); - return null; } } //Delete a repo - const deleteHandler = async () => { + const deleteHandler = async (repositoryName?: string) => { + start(); + if (!repositoryName) { + stop(); + toast.error('Repository name not found', toastOptions); + router.replace('/'); + return; + } //API Call for delete - fetch('/api/repo/id/' + router.query.slug + '/delete', { + await fetch('/api/v1/repositories/' + repositoryName, { method: 'DELETE', headers: { 'Content-type': 'application/json', }, }) - .then((response) => { + .then(async (response) => { if (response.ok) { toast.success( - 'πŸ—‘ The repository #' + router.query.slug + ' has been successfully deleted', + 'πŸ—‘ The repository ' + repositoryName + ' has been successfully deleted', toastOptions ); router.replace('/'); } else { - if (response.status == 403) + if (response.status == 403) { toast.warning( 'πŸ”’ The server is currently protected against repository deletion.', toastOptions ); - else toast.error('An error has occurred', toastOptions); - router.replace('/'); - console.log('Fail to delete'); + setIsLoading(false); + router.replace('/'); + } else { + const errorMessage = await response.json(); + toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions); + router.replace('/'); + console.log('Fail to delete'); + } } }) .catch((error) => { toast.error('An error has occurred', toastOptions); router.replace('/'); console.log(error); + }) + .finally(() => { + stop(); }); }; - //Verify that the SSH key is unique - const isSSHKeyUnique = async (sshPublicKey) => { - let isUnique = true; + const isSSHKeyUnique = async (sshPublicKey: string): Promise => { + try { + // Extract the first two columns of the SSH key in the form + const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' '); - // Extract the first two columns of the SSH key in the form - const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' '); + const response = await fetch('/api/v1/repositories', { method: 'GET' }); + const data: { repoList: Repository[] } = await response.json(); - await fetch('/api/repo', { method: 'GET' }) - .then((response) => response.json()) - .then((data) => { - for (let element in data.repoList) { - // Extract the first two columns of the SSH key in the repoList - const repoPublicKeyPrefix = data.repoList[element].sshPublicKey - .split(' ') - .slice(0, 2) - .join(' '); - - if ( - repoPublicKeyPrefix === publicKeyPrefix && // Compare the first two columns of the SSH key - (!targetRepo || data.repoList[element].id != targetRepo.id) - ) { - toast.error( - 'The SSH key is already used in repository #' + - data.repoList[element].id + - '. Please use another key or delete the key from the other repository.', - toastOptions - ); - isUnique = false; - break; - } - } - }) - .catch((error) => { - console.log(error); - toast.error('An error has occurred', toastOptions); - isUnique = false; + const conflictingRepo = data.repoList.find((repo: { sshPublicKey: string; id: number }) => { + const repoPublicKeyPrefix = repo.sshPublicKey.split(' ').slice(0, 2).join(' '); + return ( + repoPublicKeyPrefix === publicKeyPrefix && (!targetRepo || repo.id !== targetRepo.id) + ); }); - return isUnique; + + if (conflictingRepo) { + toast.error( + `The SSH key is already used in repository ${conflictingRepo.repositoryName}. Please use another key or delete the key from the other repository.`, + toastOptions + ); + return false; + } + + return true; + } catch (error) { + console.log(error); + toast.error('An error has occurred', toastOptions); + return false; + } }; //Form submit Handler for ADD or EDIT a repo - const formSubmitHandler = async (dataForm) => { - //Loading button on submit to avoid multiple send. + const formSubmitHandler = async (dataForm: DataForm) => { setIsLoading(true); + start(); + + // Clean SSH key by removing leading/trailing whitespace and line breaks + const cleanedSSHKey = dataForm.sshkey.trim(); + //Verify that the SSH key is unique - if (!(await isSSHKeyUnique(dataForm.sshkey))) { + if (!(await isSSHKeyUnique(cleanedSSHKey))) { + stop(); setIsLoading(false); return; } @@ -143,14 +165,14 @@ export default function RepoManage(props) { const newRepo = { alias: dataForm.alias, storageSize: parseInt(dataForm.storageSize), - sshPublicKey: dataForm.sshkey, + sshPublicKey: cleanedSSHKey, comment: dataForm.comment, alert: dataForm.alert.value, lanCommand: dataForm.lanCommand, appendOnlyMode: dataForm.appendOnlyMode, }; //POST API to send new repo - await fetch('/api/repo/add', { + await fetch('/api/v1/repositories', { method: 'POST', headers: { 'Content-type': 'application/json', @@ -163,7 +185,7 @@ export default function RepoManage(props) { router.replace('/'); } else { const errorMessage = await response.json(); - toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions); + toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions); router.replace('/'); console.log(`Fail to ${props.mode}`); } @@ -172,19 +194,23 @@ export default function RepoManage(props) { toast.error('An error has occurred', toastOptions); router.replace('/'); console.log(error); + }) + .finally(() => { + stop(); + setIsLoading(false); }); //EDIT a repo } else if (props.mode == 'edit') { const dataEdited = { alias: dataForm.alias, storageSize: parseInt(dataForm.storageSize), - sshPublicKey: dataForm.sshkey, + sshPublicKey: cleanedSSHKey, comment: dataForm.comment, alert: dataForm.alert.value, lanCommand: dataForm.lanCommand, appendOnlyMode: dataForm.appendOnlyMode, }; - await fetch('/api/repo/id/' + router.query.slug + '/edit', { + await fetch('/api/v1/repositories/' + targetRepo?.repositoryName, { method: 'PATCH', headers: { 'Content-type': 'application/json', @@ -194,13 +220,13 @@ export default function RepoManage(props) { .then(async (response) => { if (response.ok) { toast.success( - 'The repository #' + targetRepo.id + ' has been successfully edited !', + 'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !', toastOptions ); router.replace('/'); } else { const errorMessage = await response.json(); - toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions); + toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions); router.replace('/'); console.log(`Fail to ${props.mode}`); } @@ -209,6 +235,10 @@ export default function RepoManage(props) { toast.error('An error has occurred', toastOptions); router.replace('/'); console.log(error); + }) + .finally(() => { + stop(); + setIsLoading(false); }); } }; @@ -231,54 +261,54 @@ export default function RepoManage(props) { color: 'rgba(99, 115, 129, 0.38)', }} > - #{targetRepo.id} + {targetRepo?.repositoryName} {' '} ?
    - You are about to permanently delete the repository #{targetRepo.id} and all - the backups it contains. + You are about to permanently delete the repository{' '} + {targetRepo?.repositoryName} and all the backups it contains.
    The data will not be recoverable and it will not be possible to go back.
    - {isLoading ? ( - - ) : ( - <> - - - - )} + <> + + +
    ) : (
    {props.mode == 'edit' && ( -

    +

    Edit the repository{' '} - #{targetRepo.id} + {targetRepo?.repositoryName} -

    + )} - {props.mode == 'add' &&

    Add a repository

    } + {props.mode == 'add' &&

    Add a repository

    }
    {/* ALIAS */} @@ -286,16 +316,16 @@ export default function RepoManage(props) { className='form-control is-invalid' placeholder='Alias for the repository, e.g."Server 1"' type='text' - defaultValue={props.mode == 'edit' ? targetRepo.alias : null} + defaultValue={props.mode == 'edit' ? targetRepo?.alias : undefined} {...register('alias', { required: 'An alias is required.', minLength: { - value: 2, - message: '2 characters min', + value: 1, + message: '1 character min', }, maxLength: { - value: 40, - message: '40 characters max', + value: 100, + message: '100 characters max', }, })} /> @@ -304,15 +334,17 @@ export default function RepoManage(props) {