mirror of
https://github.com/Sebclem/hassio-nextcloud-backup.git
synced 2024-11-22 01:02:59 +01:00
commit
8a787d403e
149
.github/workflows/build_addon.yml
vendored
149
.github/workflows/build_addon.yml
vendored
@ -2,7 +2,7 @@ name: Build addon
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [ published ]
|
types: [published]
|
||||||
push:
|
push:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@ -10,57 +10,98 @@ env:
|
|||||||
TARGET: nextcloud_backup
|
TARGET: nextcloud_backup
|
||||||
IMAGE: "hassio-nextcloud-backup"
|
IMAGE: "hassio-nextcloud-backup"
|
||||||
REPOSITORY: ghcr.io/sebclem
|
REPOSITORY: ghcr.io/sebclem
|
||||||
IMAGE_SOURCE : https://github.com/Sebclem/hassio-nextcloud-backup
|
IMAGE_SOURCE: https://github.com/Sebclem/hassio-nextcloud-backup
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
build-front:
|
||||||
|
name: Build Front
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: nextcloud_backup/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm i
|
||||||
|
working-directory: nextcloud_backup/frontend
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: nextcloud_backup/frontend
|
||||||
|
|
||||||
|
- name: Upload production-ready build files
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: front_dist
|
||||||
|
path: nextcloud_backup/frontend/dist/
|
||||||
|
|
||||||
|
build-back:
|
||||||
|
name: Build back
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: nextcloud_backup/backend/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm i
|
||||||
|
working-directory: nextcloud_backup/backend
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
working-directory: nextcloud_backup/backend
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: nextcloud_backup/backend
|
||||||
|
|
||||||
|
- name: Upload production-ready build files
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: back_dist
|
||||||
|
path: nextcloud_backup/backend/dist/
|
||||||
|
|
||||||
build-dockers:
|
build-dockers:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ aarch64, amd64, armv7, i386, armhf ]
|
arch: [ aarch64, amd64, armv7 ]
|
||||||
include:
|
include:
|
||||||
- arch: aarch64
|
- arch: aarch64
|
||||||
arch_value: linux/arm64/v8
|
arch_value: linux/arm64/v8
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
arch_value: linux/amd64
|
arch_value: linux/amd64
|
||||||
- arch: armv7
|
- arch: armv7
|
||||||
arch_value: linux/arm/v7
|
arch_value: linux/arm/v7
|
||||||
- arch: i386
|
|
||||||
arch_value: linux/386
|
|
||||||
- arch: armhf
|
|
||||||
arch_value: linux/arm/v6
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx ${{matrix.arch}}
|
- name: Set up Docker Buildx ${{matrix.arch}}
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set Version Prod ${{matrix.arch}}
|
|
||||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'push'
|
|
||||||
run: |
|
|
||||||
version=${GITHUB_REF/refs\/tags\//}
|
|
||||||
if [ -n "$version" ];then
|
|
||||||
tmp=$(mktemp)
|
|
||||||
jq --arg version "$version" '.version=$version' ${{env.TARGET}}/config.json > "$tmp" && mv "$tmp" ${{env.TARGET}}/config.json
|
|
||||||
fi
|
|
||||||
echo "version_type=prod" >> $GITHUB_ENV
|
|
||||||
- name: Set Version Test ${{matrix.arch}}
|
|
||||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
version=dev_${GITHUB_RUN_ID}
|
|
||||||
if [ -n "$version" ];then
|
|
||||||
tmp=$(mktemp)
|
|
||||||
jq --arg version "$version" '.version=$version' ${{env.TARGET}}/config.json > "$tmp" && mv "$tmp" ${{env.TARGET}}/config.json
|
|
||||||
fi
|
|
||||||
echo "version_type=dev" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
|
|
||||||
- name: Get build option ${{matrix.arch}}
|
- name: Get build option ${{matrix.arch}}
|
||||||
run: |
|
run: |
|
||||||
@ -68,38 +109,48 @@ jobs:
|
|||||||
echo "DESCRIPTION=$(jq --raw-output '.description // empty' "${{env.TARGET}}/config.json" | sed "s/'//g")" >> $GITHUB_ENV
|
echo "DESCRIPTION=$(jq --raw-output '.description // empty' "${{env.TARGET}}/config.json" | sed "s/'//g")" >> $GITHUB_ENV
|
||||||
echo "URL=$(jq --raw-output '.url // empty' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
echo "URL=$(jq --raw-output '.url // empty' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
||||||
echo "VERSION=$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
echo "VERSION=$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
||||||
echo "BUILD_FROM=ghcr.io/hassio-addons/base/${{matrix.arch}}:$(cat nextcloud_backup/.base_version)" >> $GITHUB_ENV
|
echo "BUILD_FROM=ghcr.io/home-assistant/${{matrix.arch}}-base:$(cat nextcloud_backup/.base_version)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set Tags ${{matrix.arch}}
|
- name: Docker meta
|
||||||
run: |
|
id: meta
|
||||||
if [ "${{env.version_type}}" != "dev" ]; then
|
uses: docker/metadata-action@v5
|
||||||
echo "TAGS=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest, ${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
with:
|
||||||
else
|
images: |
|
||||||
echo "TAGS=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:dev, ${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
|
${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}
|
||||||
fi
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
labels: |
|
||||||
|
io.hass.name=${{env.NAME}}
|
||||||
|
io.hass.description=${{env.DESCRIPTION}}
|
||||||
|
io.hass.url=${{env.URL}}
|
||||||
|
io.hass.arch=${{matrix.arch}}
|
||||||
|
io.hass.type=addon
|
||||||
|
|
||||||
- name: Debug Env
|
- name: Debug Env
|
||||||
run: |
|
run: |
|
||||||
echo ${{env.VERSION}}
|
echo "${{ steps.meta.outputs.tags }}"
|
||||||
echo ${{env.TAGS}}
|
echo "${{ steps.meta.outputs.labels }}"
|
||||||
|
|
||||||
- name: Login to ghcr.io
|
- name: Login to ghcr.io
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push ${{matrix.arch}}
|
- name: Build and push ${{matrix.arch}}
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{env.TAGS}}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: io.hass.name=${{env.NAME}}, io.hass.description=${{env.DESCRIPTION}}, io.hass.url=${{env.URL}}, io.hass.arch=${{matrix.arch}}, io.hass.version=${{env.VERSION}}, io.hass.type=addon
|
labels: |
|
||||||
|
${{ steps.meta.outputs.labels }}
|
||||||
|
io.hass.version=${{steps.meta.outputs.version}}
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILD_FROM=${{env.BUILD_FROM}}
|
BUILD_FROM=${{env.BUILD_FROM}}
|
||||||
BUILD_VERSION=${{env.VERSION}}
|
|
||||||
IMAGE_SOURCE=${{env.IMAGE_SOURCE}}
|
|
||||||
file: ./${{env.TARGET}}/Dockerfile
|
file: ./${{env.TARGET}}/Dockerfile
|
||||||
cache-from: type=registry,ref=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest
|
cache-from: type=registry,ref=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -103,7 +103,6 @@ dist
|
|||||||
|
|
||||||
# TernJS port file
|
# TernJS port file
|
||||||
.tern-port
|
.tern-port
|
||||||
.vscode
|
|
||||||
status.json
|
status.json
|
||||||
conf.json
|
conf.json
|
||||||
webdav_conf.json
|
webdav_conf.json
|
||||||
|
783
.yarn/releases/yarn-3.2.2.cjs
vendored
783
.yarn/releases/yarn-3.2.2.cjs
vendored
File diff suppressed because one or more lines are too long
@ -1,3 +0,0 @@
|
|||||||
nodeLinker: node-modules
|
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-3.2.2.cjs
|
|
5
deploy
5
deploy
@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes && \
|
|
||||||
docker run --rm --privileged -v ~/.docker:/root/.docker -v /var/run/docker.sock:/var/run/docker.sock -v $(pwd)/nextcloud_backup:/data homeassistant/amd64-builder --all -t /data
|
|
||||||
|
|
@ -1 +1 @@
|
|||||||
12.2.2
|
3.18
|
@ -1,55 +1,38 @@
|
|||||||
ARG BUILD_FROM=ghcr.io/hassio-addons/base/amd64:12.2.2
|
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.18
|
||||||
FROM ${BUILD_FROM}
|
|
||||||
|
FROM node:20 AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20 AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/package.json backend/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
# Copy root filesystem
|
|
||||||
COPY rootfs/etc /etc/
|
COPY rootfs/etc /etc/
|
||||||
COPY rootfs/usr /usr/
|
COPY rootfs/usr /usr/
|
||||||
|
|
||||||
# Setup base
|
RUN apk add --no-cache nodejs-current && mkdir -p /usr/local/sbin/ && ln -s /usr/bin/node /usr/local/sbin/node
|
||||||
RUN apk add --no-cache nodejs-current
|
|
||||||
|
|
||||||
# Fix for arm/v7
|
|
||||||
RUN mkdir -p /usr/local/sbin/
|
|
||||||
RUN ln -s /usr/bin/node /usr/local/sbin/node
|
|
||||||
|
|
||||||
# Copy only package*.json
|
|
||||||
COPY rootfs/opt/nextcloud_backup/package*.json /opt/nextcloud_backup/
|
|
||||||
COPY rootfs/opt/nextcloud_backup/.yarnrc.yml /opt/nextcloud_backup/
|
|
||||||
COPY rootfs/opt/nextcloud_backup/.yarn/releases/* /opt/nextcloud_backup/.yarn/releases/
|
|
||||||
|
|
||||||
WORKDIR /opt/nextcloud_backup/
|
WORKDIR /opt/nextcloud_backup/
|
||||||
|
|
||||||
# Enable Yarn
|
COPY backend/package.json backend/pnpm-lock.yaml ./
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
# Install packages
|
COPY --from=backend-builder /app/dist .
|
||||||
RUN yarn install
|
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=frontend-builder /app/dist ./public
|
||||||
# Copy all source code
|
|
||||||
COPY rootfs/opt/ /opt/
|
|
||||||
|
|
||||||
# Build arguments
|
|
||||||
ARG BUILD_ARCH
|
|
||||||
ARG BUILD_DATE
|
|
||||||
ARG BUILD_REF
|
|
||||||
ARG BUILD_VERSION
|
|
||||||
ARG IMAGE_SOURCE
|
|
||||||
|
|
||||||
# Labels
|
|
||||||
LABEL \
|
|
||||||
io.hass.name="Nextcloud Backup" \
|
|
||||||
io.hass.description="Addon that backup your snapshot to a Nextcloud server" \
|
|
||||||
io.hass.arch="${BUILD_ARCH}" \
|
|
||||||
io.hass.type="addon" \
|
|
||||||
io.hass.version=${BUILD_VERSION} \
|
|
||||||
maintainer="Sebclem" \
|
|
||||||
org.label-schema.description="Addon that backup your snapshot to a Nextcloud server" \
|
|
||||||
org.label-schema.build-date=${BUILD_DATE} \
|
|
||||||
org.label-schema.name="Nextcloud Backup" \
|
|
||||||
org.label-schema.schema-version="1.0" \
|
|
||||||
org.label-schema.url="https://addons.community" \
|
|
||||||
org.label-schema.usage="https://github.com/hassio-addons/addon-example/tree/master/README.md" \
|
|
||||||
org.label-schema.vcs-ref=${BUILD_REF} \
|
|
||||||
org.label-schema.vcs-url="https://github.com/hassio-addons/addon-example" \
|
|
||||||
org.label-schema.vendor="Sebclem"\
|
|
||||||
org.opencontainers.image.source=${IMAGE_SOURCE}
|
|
||||||
|
5
nextcloud_backup/backend/.gitignore
vendored
Normal file
5
nextcloud_backup/backend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
dist/**
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
20
nextcloud_backup/backend/.vscode/launch.json
vendored
Normal file
20
nextcloud_backup/backend/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"name": "nodemon",
|
||||||
|
"program": "${workspaceFolder}/dist/server.js",
|
||||||
|
"request": "launch",
|
||||||
|
"restart": true,
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/nodemon",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"type": "node",
|
||||||
|
"preLaunchTask": "npm: build:watch"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
12
nextcloud_backup/backend/.vscode/settings.json
vendored
Normal file
12
nextcloud_backup/backend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"typescript.inlayHints.enumMemberValues.enabled": true,
|
||||||
|
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||||
|
"typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": true,
|
||||||
|
"typescript.inlayHints.parameterNames.enabled": "literals",
|
||||||
|
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||||
|
"javascript.inlayHints.enumMemberValues.enabled": true,
|
||||||
|
"javascript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||||
|
"javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": true,
|
||||||
|
"javascript.inlayHints.parameterNames.enabled": "literals",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
14
nextcloud_backup/backend/.vscode/tasks.json
vendored
Normal file
14
nextcloud_backup/backend/.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "build:watch",
|
||||||
|
"group": "build",
|
||||||
|
"label": "npm: build:watch",
|
||||||
|
"detail": "tsc -w",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": "$tsc-watch"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -8,4 +8,6 @@
|
|||||||
- `6` => Fail to clean
|
- `6` => Fail to clean
|
||||||
- `7` => Fail to download snap
|
- `7` => Fail to download snap
|
||||||
- `8` => Fail to stop addon
|
- `8` => Fail to stop addon
|
||||||
- `9` => Fail to start addon
|
- `9` => Fail to start addon
|
||||||
|
|
||||||
|
|
20
nextcloud_backup/backend/eslint.config.js
Normal file
20
nextcloud_backup/backend/eslint.config.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "eslint.config.js"]
|
||||||
|
}
|
||||||
|
);
|
57
nextcloud_backup/backend/package.json
Normal file
57
nextcloud_backup/backend/package.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "nexcloud-backup",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:watch": "tsc -w",
|
||||||
|
"build": "tsc --build --verbose",
|
||||||
|
"dev": "pnpm build && concurrently -n tsc,node \"pnpm build:watch\" \"pnpm serve:watch\"",
|
||||||
|
"lint": "tsc --noEmit && eslint --quiet --fix",
|
||||||
|
"serve:watch": "nodemon dist/server.js",
|
||||||
|
"serve": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"app-root-path": "3.1.0",
|
||||||
|
"cookie-parser": "1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cron": "3.1.7",
|
||||||
|
"debug": "4.3.4",
|
||||||
|
"errorhandler": "^1.5.1",
|
||||||
|
"express": "4.18.2",
|
||||||
|
"fast-xml-parser": "^4.3.4",
|
||||||
|
"figlet": "^1.7.0",
|
||||||
|
"form-data": "4.0.0",
|
||||||
|
"got": "14.2.0",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"joi": "^17.12.1",
|
||||||
|
"jquery": "3.7.1",
|
||||||
|
"kleur": "^4.1.5",
|
||||||
|
"luxon": "3.4.4",
|
||||||
|
"morgan": "1.10.0",
|
||||||
|
"webdav": "5.3.2",
|
||||||
|
"winston": "3.11.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.15.3",
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.6.0",
|
||||||
|
"@tsconfig/recommended": "^1.0.3",
|
||||||
|
"@types/cookie-parser": "^1.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/errorhandler": "^1.5.3",
|
||||||
|
"@types/eslint__js": "^8.42.3",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/figlet": "^1.5.8",
|
||||||
|
"@types/http-errors": "^2.0.4",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.11.19",
|
||||||
|
"concurrently": "8.2.2",
|
||||||
|
"dotenv": "^16.4.4",
|
||||||
|
"eslint": "^9.6.0",
|
||||||
|
"nodemon": "^3.0.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"typescript-eslint": "8.0.0-alpha.41"
|
||||||
|
}
|
||||||
|
}
|
2577
nextcloud_backup/backend/pnpm-lock.yaml
Normal file
2577
nextcloud_backup/backend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
42
nextcloud_backup/backend/src/app.ts
Normal file
42
nextcloud_backup/backend/src/app.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
import morgan from "morgan";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import logger from "./config/winston.js";
|
||||||
|
import apiV2Router from "./routes/apiV2.js";
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.set("port", process.env.PORT || 3000);
|
||||||
|
if (process.env.ACCESS_LOG == "true") {
|
||||||
|
app.use(
|
||||||
|
morgan("dev", { stream: { write: (message) => logger.debug(message) } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
app.use("/v2/api/", apiV2Router);
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "public/index.html"));
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
-----------------------------------------------------------
|
||||||
|
Error handler
|
||||||
|
----------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default app;
|
@ -1,5 +1,4 @@
|
|||||||
import winston from "winston";
|
import winston from "winston";
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
2
nextcloud_backup/backend/src/env.ts
Normal file
2
nextcloud_backup/backend/src/env.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
86
nextcloud_backup/backend/src/postInit.ts
Normal file
86
nextcloud_backup/backend/src/postInit.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
import logger from "./config/winston.js";
|
||||||
|
import * as homeAssistantService from "./services/homeAssistantService.js";
|
||||||
|
import * as statusTools from "./tools/status.js";
|
||||||
|
import kleur from "kleur";
|
||||||
|
import {
|
||||||
|
checkWebdavLogin,
|
||||||
|
createBackupFolder,
|
||||||
|
} from "./services/webdavService.js";
|
||||||
|
import {
|
||||||
|
getWebdavConfig,
|
||||||
|
validateWebdavConfig,
|
||||||
|
} from "./services/webdavConfigService.js";
|
||||||
|
import messageManager from "./tools/messageManager.js";
|
||||||
|
import { initCron } from "./services/cronService.js";
|
||||||
|
import { getBackupConfig } from "./services/backupConfigService.js";
|
||||||
|
|
||||||
|
function postInit() {
|
||||||
|
logger.info(`Log level: ${process.env.LOG_LEVEL}`);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Backup timeout: ${
|
||||||
|
(process.env.CREATE_BACKUP_TIMEOUT
|
||||||
|
? parseInt(process.env.CREATE_BACKUP_TIMEOUT)
|
||||||
|
: false) || 90 * 60 * 1000
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync("/data")) mkdirSync("/data");
|
||||||
|
statusTools.init();
|
||||||
|
logger.info("Satus : " + kleur.green().bold("Go !"));
|
||||||
|
|
||||||
|
homeAssistantService.getBackups().then(
|
||||||
|
() => {
|
||||||
|
logger.info("Hassio API : " + kleur.green().bold("Go !"));
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
logger.error("Hassio API : " + kleur.red().bold("FAIL !"));
|
||||||
|
logger.error("... " + err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const webdavConf = getWebdavConfig();
|
||||||
|
validateWebdavConfig(webdavConf)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
logger.info("Webdav config: " + kleur.green().bold("Go !"));
|
||||||
|
return checkWebdavLogin(webdavConf);
|
||||||
|
},
|
||||||
|
(reason: Error) => {
|
||||||
|
logger.error("Webdav config: " + kleur.red().bold("FAIL !"));
|
||||||
|
logger.error(reason);
|
||||||
|
messageManager.error("Invalid webdav config", reason.message);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
logger.info("Webdav : " + kleur.green().bold("Go !"));
|
||||||
|
return createBackupFolder(webdavConf);
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
logger.error("Webdav : " + kleur.red().bold("FAIL !"));
|
||||||
|
logger.error(reason);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
logger.info("Webdav fodlers: " + kleur.green().bold("Go !"));
|
||||||
|
return initCron(getBackupConfig());
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
logger.error("Webdav folders: " + kleur.red().bold("FAIL !"));
|
||||||
|
logger.error(reason);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
logger.info("Cron: " + kleur.green().bold("Go !"));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
logger.info("Cron: " + kleur.red().bold("FAIL !"));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default postInit;
|
39
nextcloud_backup/backend/src/routes/action.ts
Normal file
39
nextcloud_backup/backend/src/routes/action.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { doBackupWorkflow } from "../services/orchestrator.js";
|
||||||
|
import { WorkflowType } from "../types/services/orchecstrator.js";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import { clean as webdavClean } from "../services/webdavService.js";
|
||||||
|
import { getBackupConfig } from "../services/backupConfigService.js";
|
||||||
|
import { getWebdavConfig } from "../services/webdavConfigService.js";
|
||||||
|
import { clean } from "../services/homeAssistantService.js";
|
||||||
|
|
||||||
|
const actionRouter = express.Router();
|
||||||
|
|
||||||
|
actionRouter.post("/backup", (req, res) => {
|
||||||
|
doBackupWorkflow(WorkflowType.MANUAL)
|
||||||
|
.then(() => {
|
||||||
|
logger.info("All good !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logger.error("Something wrong !");
|
||||||
|
});
|
||||||
|
res.sendStatus(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
actionRouter.post("/clean", (req, res) => {
|
||||||
|
const backupConfig = getBackupConfig();
|
||||||
|
const webdavConfig = getWebdavConfig();
|
||||||
|
webdavClean(backupConfig, webdavConfig)
|
||||||
|
.then(() => {
|
||||||
|
return clean(backupConfig);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info("All good !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logger.error("Something wrong !");
|
||||||
|
});
|
||||||
|
res.sendStatus(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default actionRouter;
|
18
nextcloud_backup/backend/src/routes/apiV2.ts
Normal file
18
nextcloud_backup/backend/src/routes/apiV2.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import express from "express";
|
||||||
|
import configRouter from "./config.js";
|
||||||
|
import homeAssistant from "./homeAssistant.js";
|
||||||
|
import messageRouter from "./messages.js";
|
||||||
|
import webdavRouter from "./webdav.js";
|
||||||
|
import statusRouter from "./status.js";
|
||||||
|
import actionRouter from "./action.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use("/homeAssistant", homeAssistant);
|
||||||
|
router.use("/config", configRouter);
|
||||||
|
router.use("/webdav", webdavRouter);
|
||||||
|
router.use("/messages", messageRouter);
|
||||||
|
router.use("/status", statusRouter);
|
||||||
|
router.use("/action", actionRouter);
|
||||||
|
|
||||||
|
export default router;
|
63
nextcloud_backup/backend/src/routes/config.ts
Normal file
63
nextcloud_backup/backend/src/routes/config.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getBackupConfig,
|
||||||
|
saveBackupConfig,
|
||||||
|
validateBackupConfig,
|
||||||
|
} from "../services/backupConfigService.js";
|
||||||
|
import {
|
||||||
|
getWebdavConfig,
|
||||||
|
saveWebdavConfig,
|
||||||
|
validateWebdavConfig,
|
||||||
|
} from "../services/webdavConfigService.js";
|
||||||
|
import { checkWebdavLogin } from "../services/webdavService.js";
|
||||||
|
import type { BackupConfig } from "../types/services/backupConfig.js";
|
||||||
|
import type { ValidationError } from "joi";
|
||||||
|
import type { WebdavConfig } from "../types/services/webdavConfig.js";
|
||||||
|
|
||||||
|
const configRouter = express.Router();
|
||||||
|
|
||||||
|
configRouter.get("/backup", (req, res) => {
|
||||||
|
res.json(getBackupConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
configRouter.put("/backup", (req, res) => {
|
||||||
|
validateBackupConfig(req.body as BackupConfig)
|
||||||
|
.then(() => {
|
||||||
|
return saveBackupConfig(req.body as BackupConfig);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
.catch((error: ValidationError) => {
|
||||||
|
if (error.details) {
|
||||||
|
res.status(400).json({ type: "validation", errors: error.details });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ type: "cron", errors: [error.message] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
configRouter.get("/webdav", (req, res) => {
|
||||||
|
res.json(getWebdavConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
configRouter.put("/webdav", (req, res) => {
|
||||||
|
validateWebdavConfig(req.body as WebdavConfig)
|
||||||
|
.then(() => {
|
||||||
|
return checkWebdavLogin(req.body as WebdavConfig, true);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
saveWebdavConfig(req.body as WebdavConfig);
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
.catch((error: ValidationError) => {
|
||||||
|
res.status(400);
|
||||||
|
if (error.details) {
|
||||||
|
res.json({ type: "validation", errors: error.details });
|
||||||
|
} else {
|
||||||
|
res.json({ type: "validation", errors: error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default configRouter;
|
71
nextcloud_backup/backend/src/routes/homeAssistant.ts
Normal file
71
nextcloud_backup/backend/src/routes/homeAssistant.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import express from "express";
|
||||||
|
import * as haOsService from "../services/homeAssistantService.js";
|
||||||
|
import { uploadToCloud } from "../services/orchestrator.js";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
|
||||||
|
const homeAssistantRouter = express.Router();
|
||||||
|
|
||||||
|
homeAssistantRouter.get("/backups/", (req, res) => {
|
||||||
|
haOsService
|
||||||
|
.getBackups()
|
||||||
|
.then((value) => {
|
||||||
|
res.json(
|
||||||
|
value.body.data.backups.sort(
|
||||||
|
(a, b) => Date.parse(b.date) - Date.parse(a.date)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500).json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
homeAssistantRouter.get("/backup/:slug", (req, res) => {
|
||||||
|
haOsService
|
||||||
|
.getBackupInfo(req.params.slug)
|
||||||
|
.then((value) => {
|
||||||
|
res.json(value.body.data);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500).json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
homeAssistantRouter.delete("/backup/:slug", (req, res) => {
|
||||||
|
haOsService
|
||||||
|
.delSnap(req.params.slug)
|
||||||
|
.then((value) => {
|
||||||
|
res.json(value.body);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500).json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
homeAssistantRouter.post("/backup/:slug/upload", (req, res) => {
|
||||||
|
uploadToCloud(req.params.slug)
|
||||||
|
.then(() => {
|
||||||
|
logger.info("All good !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logger.error("Something wrong !");
|
||||||
|
});
|
||||||
|
res.sendStatus(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
homeAssistantRouter.get("/addons", (req, res) => {
|
||||||
|
haOsService
|
||||||
|
.getAddonList()
|
||||||
|
.then((value) => {
|
||||||
|
res.json(value.body.data);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500).json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
homeAssistantRouter.get("/folders", (req, res) => {
|
||||||
|
res.json(haOsService.getFolderList());
|
||||||
|
});
|
||||||
|
|
||||||
|
export default homeAssistantRouter;
|
23
nextcloud_backup/backend/src/routes/messages.ts
Normal file
23
nextcloud_backup/backend/src/routes/messages.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import express from "express";
|
||||||
|
import messageManager from "../tools/messageManager.js";
|
||||||
|
|
||||||
|
const messageRouter = express.Router();
|
||||||
|
|
||||||
|
messageRouter.get("/", (req, res) => {
|
||||||
|
res.json(messageManager.get());
|
||||||
|
});
|
||||||
|
|
||||||
|
messageRouter.patch("/:messageId/readed", (req, res) => {
|
||||||
|
if (messageManager.markReaded(req.params.messageId)) {
|
||||||
|
res.json(messageManager.get());
|
||||||
|
} else {
|
||||||
|
res.sendStatus(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
messageRouter.post("/allReaded", (req, res) => {
|
||||||
|
messageManager.markAllReaded();
|
||||||
|
res.json(messageManager.get());
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messageRouter;
|
12
nextcloud_backup/backend/src/routes/status.ts
Normal file
12
nextcloud_backup/backend/src/routes/status.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getStatus } from "../tools/status.js";
|
||||||
|
|
||||||
|
const statusRouter = express.Router();
|
||||||
|
|
||||||
|
|
||||||
|
statusRouter.get('/', (req, res) => {
|
||||||
|
res.json(getStatus());
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export default statusRouter
|
103
nextcloud_backup/backend/src/routes/webdav.ts
Normal file
103
nextcloud_backup/backend/src/routes/webdav.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import express from "express";
|
||||||
|
import Joi from "joi";
|
||||||
|
import { getBackupConfig } from "../services/backupConfigService.js";
|
||||||
|
import {
|
||||||
|
getWebdavConfig,
|
||||||
|
validateWebdavConfig,
|
||||||
|
} from "../services/webdavConfigService.js";
|
||||||
|
import * as webdavService from "../services/webdavService.js";
|
||||||
|
import * as pathTools from "../tools/pathTools.js";
|
||||||
|
import type { WebdavGenericPath } from "../types/services/webdav.js";
|
||||||
|
import { WebdavDeleteValidation } from "../types/services/webdavValidation.js";
|
||||||
|
import { restoreToHA } from "../services/orchestrator.js";
|
||||||
|
import path from "path";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
|
||||||
|
const webdavRouter = express.Router();
|
||||||
|
|
||||||
|
webdavRouter.get("/backup/auto", (req, res) => {
|
||||||
|
const config = getWebdavConfig();
|
||||||
|
const backupConf = getBackupConfig();
|
||||||
|
validateWebdavConfig(config)
|
||||||
|
.then(() => {
|
||||||
|
return webdavService.checkWebdavLogin(config);
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
const value = await webdavService.getBackups(
|
||||||
|
pathTools.auto,
|
||||||
|
config,
|
||||||
|
backupConf.nameTemplate
|
||||||
|
);
|
||||||
|
res.json(value);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500);
|
||||||
|
res.json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
webdavRouter.get("/backup/manual", (req, res) => {
|
||||||
|
const config = getWebdavConfig();
|
||||||
|
const backupConf = getBackupConfig();
|
||||||
|
validateWebdavConfig(config)
|
||||||
|
.then(() => {
|
||||||
|
return webdavService.checkWebdavLogin(config);
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
const value = await webdavService.getBackups(
|
||||||
|
pathTools.manual,
|
||||||
|
config,
|
||||||
|
backupConf.nameTemplate
|
||||||
|
);
|
||||||
|
res.json(value);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500);
|
||||||
|
res.json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
webdavRouter.delete("/", (req, res) => {
|
||||||
|
const body = req.body as WebdavGenericPath;
|
||||||
|
const validator = Joi.object(WebdavDeleteValidation);
|
||||||
|
const config = getWebdavConfig();
|
||||||
|
validateWebdavConfig(config)
|
||||||
|
.then(() => {
|
||||||
|
return validator.validateAsync(body);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return webdavService.checkWebdavLogin(config);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
webdavService
|
||||||
|
.deleteBackup(body.path, config)
|
||||||
|
.then(() => {
|
||||||
|
res.status(201).send();
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(500).json(reason);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
res.status(400).json(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
webdavRouter.post("/restore", (req, res) => {
|
||||||
|
const body = req.body as WebdavGenericPath;
|
||||||
|
const validator = Joi.object(WebdavDeleteValidation);
|
||||||
|
validator
|
||||||
|
.validateAsync(body)
|
||||||
|
.then(() => {
|
||||||
|
return restoreToHA(body.path, path.basename(body.path));
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info("All good !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logger.error("Something wrong !");
|
||||||
|
});
|
||||||
|
res.sendStatus(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webdavRouter;
|
37
nextcloud_backup/backend/src/server.ts
Executable file
37
nextcloud_backup/backend/src/server.ts
Executable file
@ -0,0 +1,37 @@
|
|||||||
|
import "./env.js";
|
||||||
|
|
||||||
|
import errorHandler from "errorhandler";
|
||||||
|
import figlet from "figlet";
|
||||||
|
import createError from "http-errors";
|
||||||
|
import kleur from "kleur";
|
||||||
|
import app from "./app.js";
|
||||||
|
import logger from "./config/winston.js";
|
||||||
|
import postInit from "./postInit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error Handler. Provides full stack
|
||||||
|
*/
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
app.use(errorHandler());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
next(createError(404));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Express server.
|
||||||
|
*/
|
||||||
|
const server = app.listen(app.get("port"), () => {
|
||||||
|
console.log(kleur.yellow().bold(figlet.textSync("NC Backup")));
|
||||||
|
logger.info(
|
||||||
|
`App is running at ` +
|
||||||
|
kleur.green().bold(`http://localhost:${app.get("port")}`) +
|
||||||
|
" in " +
|
||||||
|
kleur.green().bold(process.env.NODE_ENV || "production") +
|
||||||
|
" mode"
|
||||||
|
);
|
||||||
|
logger.info(kleur.red().bold("Press CTRL-C to stop"));
|
||||||
|
postInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default server;
|
92
nextcloud_backup/backend/src/services/backupConfigService.ts
Normal file
92
nextcloud_backup/backend/src/services/backupConfigService.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import Joi from "joi";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import {
|
||||||
|
type BackupConfig,
|
||||||
|
BackupType,
|
||||||
|
} from "../types/services/backupConfig.js";
|
||||||
|
import backupConfigValidation from "../types/services/backupConfigValidation.js";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { WorkflowType } from "../types/services/orchecstrator.js";
|
||||||
|
import { initCron } from "./cronService.js";
|
||||||
|
|
||||||
|
const backupConfigPath = "/data/backupConfigV2.json";
|
||||||
|
|
||||||
|
export function validateBackupConfig(config: BackupConfig) {
|
||||||
|
const validator = Joi.object(backupConfigValidation);
|
||||||
|
return validator.validateAsync(config, {
|
||||||
|
abortEarly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBackupConfig(config: BackupConfig) {
|
||||||
|
fs.writeFileSync(backupConfigPath, JSON.stringify(config, undefined, 2));
|
||||||
|
return initCron(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupConfig(): BackupConfig {
|
||||||
|
if (!fs.existsSync(backupConfigPath)) {
|
||||||
|
logger.warn("Config file not found, creating default one !");
|
||||||
|
const defaultConfig = getBackupDefaultConfig();
|
||||||
|
saveBackupConfig(defaultConfig).catch(() => {});
|
||||||
|
return defaultConfig;
|
||||||
|
} else {
|
||||||
|
return JSON.parse(
|
||||||
|
fs.readFileSync(backupConfigPath).toString()
|
||||||
|
) as BackupConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupDefaultConfig(): BackupConfig {
|
||||||
|
return {
|
||||||
|
nameTemplate: "{type}-{ha_version}-{date}_{hour}",
|
||||||
|
cron: [],
|
||||||
|
autoClean: {
|
||||||
|
homeAssistant: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
webdav: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backupType: BackupType.FULL,
|
||||||
|
autoStopAddon: [],
|
||||||
|
password: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateToRegexp(template: string) {
|
||||||
|
let regexp = template.replace("{date}", "(?<date>\\d{4}-\\d{2}-\\d{2})");
|
||||||
|
regexp = regexp.replace("{hour}", "(?<hour>\\d{4})");
|
||||||
|
regexp = regexp.replace("{hour_12}", "(?<hour12>\\d{4}(AM|PM))");
|
||||||
|
regexp = regexp.replace("{type}", "(?<type>Auto|Manual|)");
|
||||||
|
regexp = regexp.replace("{type_low}", "(?<type>auto|manual|)");
|
||||||
|
return regexp.replace(
|
||||||
|
"{ha_version}",
|
||||||
|
"(?<version>\\d+\\.\\d+\\.\\d+(b\\d+)?)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormatedName(
|
||||||
|
workflowType: WorkflowType,
|
||||||
|
ha_version: string
|
||||||
|
) {
|
||||||
|
const setting = getBackupConfig();
|
||||||
|
let template = setting.nameTemplate;
|
||||||
|
template = template.replace(
|
||||||
|
"{type_low}",
|
||||||
|
workflowType == WorkflowType.MANUAL ? "manual" : "auto"
|
||||||
|
);
|
||||||
|
template = template.replace(
|
||||||
|
"{type}",
|
||||||
|
workflowType == WorkflowType.MANUAL ? "Manual" : "Auto"
|
||||||
|
);
|
||||||
|
template = template.replace("{ha_version}", ha_version);
|
||||||
|
const now = DateTime.now().setLocale("en");
|
||||||
|
template = template.replace("{hour_12}", now.toFormat("hhmma"));
|
||||||
|
template = template.replace("{hour}", now.toFormat("HHmm"));
|
||||||
|
template = template.replace("{date}", now.toFormat("yyyy-MM-dd"));
|
||||||
|
return template;
|
||||||
|
}
|
119
nextcloud_backup/backend/src/services/cronService.ts
Normal file
119
nextcloud_backup/backend/src/services/cronService.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { CronJob } from "cron";
|
||||||
|
import {
|
||||||
|
CronMode,
|
||||||
|
type BackupConfig,
|
||||||
|
type CronConfig,
|
||||||
|
} from "../types/services/backupConfig.js";
|
||||||
|
import { WorkflowType } from "../types/services/orchecstrator.js";
|
||||||
|
import { doBackupWorkflow } from "./orchestrator.js";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { getStatus, setStatus } from "../tools/status.js";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
|
||||||
|
let cronList: Map<string, CronJob>;
|
||||||
|
|
||||||
|
export function initCron(backupConfig: BackupConfig) {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const fn = doBackupWorkflow;
|
||||||
|
if (cronList) {
|
||||||
|
stopAllCron(cronList);
|
||||||
|
}
|
||||||
|
cronList = new Map();
|
||||||
|
for (const cronItem of backupConfig.cron) {
|
||||||
|
try {
|
||||||
|
if (cronItem.mode == CronMode.DAILY) {
|
||||||
|
cronList.set(cronItem.id, getDailyCron(cronItem, fn));
|
||||||
|
} else if (cronItem.mode == CronMode.WEEKLY) {
|
||||||
|
cronList.set(cronItem.id, getWeeklyCron(cronItem, fn));
|
||||||
|
} else if (cronItem.mode == CronMode.MONTHLY) {
|
||||||
|
cronList.set(cronItem.id, getMonthlyCron(cronItem, fn));
|
||||||
|
} else if (cronItem.mode == CronMode.CUSTOM) {
|
||||||
|
cronList.set(cronItem.id, getCustomCron(cronItem, fn));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error(`Fail to init CRON ${cronItem.id} (${cronItem.mode})`);
|
||||||
|
stopAllCron(cronList);
|
||||||
|
rej(Error(cronItem.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextDate = getNextDate(cronList);
|
||||||
|
const status = getStatus();
|
||||||
|
status.next_backup = nextDate;
|
||||||
|
setStatus(status);
|
||||||
|
res(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextDate(cronList: Map<string, CronJob>) {
|
||||||
|
let nextDate: DateTime | undefined = undefined;
|
||||||
|
for (const item of cronList) {
|
||||||
|
const thisDate = item[1].nextDate();
|
||||||
|
if (!nextDate) {
|
||||||
|
nextDate = thisDate;
|
||||||
|
}
|
||||||
|
if (nextDate > thisDate) {
|
||||||
|
nextDate = thisDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAllCron(cronList: Map<string, CronJob>) {
|
||||||
|
for (const item of cronList) {
|
||||||
|
item[1].stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getDailyCron(
|
||||||
|
config: CronConfig,
|
||||||
|
fn: (type: WorkflowType) => Promise<void>
|
||||||
|
) {
|
||||||
|
const splited = (config.hour as string).split(":");
|
||||||
|
return new CronJob(
|
||||||
|
`${splited[1]} ${splited[0]} * * *`,
|
||||||
|
() => fn(WorkflowType.AUTO),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeeklyCron(
|
||||||
|
config: CronConfig,
|
||||||
|
fn: (type: WorkflowType) => Promise<void>
|
||||||
|
) {
|
||||||
|
const splited = (config.hour as string).split(":");
|
||||||
|
return new CronJob(
|
||||||
|
`${splited[1]} ${splited[0]} * * ${config.weekday}`,
|
||||||
|
() => fn(WorkflowType.AUTO),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthlyCron(
|
||||||
|
config: CronConfig,
|
||||||
|
fn: (type: WorkflowType) => Promise<void>
|
||||||
|
) {
|
||||||
|
const splited = (config.hour as string).split(":");
|
||||||
|
return new CronJob(
|
||||||
|
`${splited[1]} ${splited[0]} ${config.monthDay} * *`,
|
||||||
|
() => fn(WorkflowType.AUTO),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomCron(
|
||||||
|
config: CronConfig,
|
||||||
|
fn: (type: WorkflowType) => Promise<void>
|
||||||
|
) {
|
||||||
|
return new CronJob(
|
||||||
|
config.custom as string,
|
||||||
|
() => fn(WorkflowType.AUTO),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
}
|
564
nextcloud_backup/backend/src/services/homeAssistantService.ts
Normal file
564
nextcloud_backup/backend/src/services/homeAssistantService.ts
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
import FormData from "form-data";
|
||||||
|
import got, {
|
||||||
|
RequestError,
|
||||||
|
type OptionsOfJSONResponseBody,
|
||||||
|
type Progress,
|
||||||
|
type Response,
|
||||||
|
} from "got";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import stream from "stream";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import messageManager from "../tools/messageManager.js";
|
||||||
|
import * as statusTools from "../tools/status.js";
|
||||||
|
import {
|
||||||
|
BackupType,
|
||||||
|
type BackupConfig,
|
||||||
|
} from "../types/services/backupConfig.js";
|
||||||
|
import type { NewBackupPayload } from "../types/services/ha_os_payload.js";
|
||||||
|
import type {
|
||||||
|
AddonData,
|
||||||
|
BackupData,
|
||||||
|
BackupDetailModel,
|
||||||
|
CoreInfoBody,
|
||||||
|
SupervisorResponse,
|
||||||
|
} from "../types/services/ha_os_response.js";
|
||||||
|
import { States } from "../types/status.js";
|
||||||
|
|
||||||
|
const pipeline = promisify(stream.pipeline);
|
||||||
|
|
||||||
|
const token = process.env.SUPERVISOR_TOKEN;
|
||||||
|
|
||||||
|
// Default timeout to 90min
|
||||||
|
const create_snap_timeout = process.env.CREATE_BACKUP_TIMEOUT
|
||||||
|
? parseInt(process.env.CREATE_BACKUP_TIMEOUT)
|
||||||
|
: 90 * 60 * 1000;
|
||||||
|
|
||||||
|
function getVersion(): Promise<Response<SupervisorResponse<CoreInfoBody>>> {
|
||||||
|
return got<SupervisorResponse<CoreInfoBody>>("http://hassio/core/info", {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
}).then(
|
||||||
|
(result) => {
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(error: Error) => {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to fetch Home Assistant version",
|
||||||
|
error?.message
|
||||||
|
);
|
||||||
|
logger.error(`Fail to fetch Home Assistant version`);
|
||||||
|
logger.error(error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonList(): Promise<Response<SupervisorResponse<AddonData>>> {
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
};
|
||||||
|
return got<SupervisorResponse<AddonData>>(
|
||||||
|
"http://hassio/addons",
|
||||||
|
option
|
||||||
|
).then(
|
||||||
|
(result) => {
|
||||||
|
result.body.data.addons.sort((a, b) => {
|
||||||
|
const textA = a.name.toUpperCase();
|
||||||
|
const textB = b.name.toUpperCase();
|
||||||
|
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(error: RequestError) => {
|
||||||
|
messageManager.error("Fail to fetch addons list", error?.message);
|
||||||
|
logger.error(`Fail to fetch addons list (${error?.message})`);
|
||||||
|
logger.error(error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackups(): Promise<Response<SupervisorResponse<BackupData>>> {
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
};
|
||||||
|
return got<SupervisorResponse<BackupData>>(
|
||||||
|
"http://hassio/backups",
|
||||||
|
option
|
||||||
|
).then(
|
||||||
|
(result) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.hass.ok = true;
|
||||||
|
status.hass.last_check = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(error: RequestError) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.hass.ok = false;
|
||||||
|
status.hass.last_check = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
messageManager.error("Fail to fetch Hassio backups", error?.message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSnapshot(id: string): Promise<string> {
|
||||||
|
logger.info(`Downloading snapshot ${id}...`);
|
||||||
|
if (!fs.existsSync("./temp/")) {
|
||||||
|
fs.mkdirSync("./temp/");
|
||||||
|
}
|
||||||
|
const tmp_file = `./temp/${id}.tar`;
|
||||||
|
const stream = fs.createWriteStream(tmp_file);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_DOWNLOAD_HA;
|
||||||
|
status.progress = 0;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
const option = {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
};
|
||||||
|
|
||||||
|
return pipeline(
|
||||||
|
got.stream
|
||||||
|
.get(`http://hassio/backups/${id}/download`, option)
|
||||||
|
.on("downloadProgress", (e: Progress) => {
|
||||||
|
const percent = Math.round(e.percent * 100) / 100;
|
||||||
|
if (status.progress !== percent) {
|
||||||
|
status.progress = percent;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
stream
|
||||||
|
).then(
|
||||||
|
() => {
|
||||||
|
logger.info("Download success !");
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.debug(
|
||||||
|
"Snapshot dl size : " + fs.statSync(tmp_file).size / 1024 / 1024
|
||||||
|
);
|
||||||
|
return tmp_file;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
fs.unlinkSync(tmp_file);
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to download Home Assistant backup",
|
||||||
|
reason.message
|
||||||
|
);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delSnap(id: string) {
|
||||||
|
logger.debug(`Deleting Home Assistant backup ${id}`);
|
||||||
|
const option = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
};
|
||||||
|
return got.delete(`http://hassio/backups/${id}`, option).then(
|
||||||
|
(result) => {
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to delete Homme assistant backup detail.",
|
||||||
|
reason.message
|
||||||
|
);
|
||||||
|
logger.error("Fail to retrive Homme assistant backup detail.");
|
||||||
|
logger.error(reason);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupInfo(id: string) {
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
};
|
||||||
|
return got<SupervisorResponse<BackupDetailModel>>(
|
||||||
|
`http://hassio/backups/${id}/info`,
|
||||||
|
option
|
||||||
|
).then(
|
||||||
|
(result) => {
|
||||||
|
logger.info("Backup found !");
|
||||||
|
logger.debug(`Backup size: ${result.body.data.size}`);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to retrive Homme assistant backup detail.",
|
||||||
|
reason.message
|
||||||
|
);
|
||||||
|
logger.error("Fail to retrive Homme assistant backup detail");
|
||||||
|
logger.error(reason);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewBackup(
|
||||||
|
name: string,
|
||||||
|
type: BackupType,
|
||||||
|
passwordEnable: boolean,
|
||||||
|
password?: string,
|
||||||
|
addonSlugs?: string[],
|
||||||
|
folders?: string[]
|
||||||
|
) {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_CREATION;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.info("Creating new snapshot...");
|
||||||
|
const body: NewBackupPayload = {
|
||||||
|
name: name,
|
||||||
|
password: passwordEnable ? password : undefined,
|
||||||
|
addons: type == BackupType.PARTIAL ? addonSlugs : undefined,
|
||||||
|
folders: type == BackupType.PARTIAL ? folders : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
timeout: {
|
||||||
|
response: create_snap_timeout,
|
||||||
|
},
|
||||||
|
json: body,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url =
|
||||||
|
type == BackupType.PARTIAL
|
||||||
|
? "http://hassio/backups/new/partial"
|
||||||
|
: "http://hassio/backups/new/full";
|
||||||
|
return got.post<SupervisorResponse<{ slug: string }>>(url, option).then(
|
||||||
|
(result) => {
|
||||||
|
logger.info(`Snapshot created with id ${result.body.data.slug}`);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
messageManager.error("Fail to create new backup.", reason.message);
|
||||||
|
logger.error("Fail to create new backup");
|
||||||
|
logger.error(reason);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(backupConfig: BackupConfig) {
|
||||||
|
if (!backupConfig.autoClean.homeAssistant.enabled) {
|
||||||
|
logger.debug("Clean disabled for Home Assistant");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
logger.info("Clean for Home Assistant");
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.CLEAN_HA;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
|
||||||
|
const numberToKeep = backupConfig.autoClean.homeAssistant.nbrToKeep || 5;
|
||||||
|
return getBackups()
|
||||||
|
.then((response) => {
|
||||||
|
const backups = response.body.data.backups;
|
||||||
|
if (backups.length > numberToKeep) {
|
||||||
|
backups.sort((a, b) => {
|
||||||
|
return Date.parse(b.date) - Date.parse(a.date);
|
||||||
|
});
|
||||||
|
const toDel = backups.slice(numberToKeep);
|
||||||
|
logger.debug(`Number of backup to clean: ${toDel.length}`);
|
||||||
|
const promises = toDel.map((value) => delSnap(value.slug));
|
||||||
|
logger.info("Home Assistant clean done.");
|
||||||
|
return Promise.allSettled(promises);
|
||||||
|
} else {
|
||||||
|
logger.debug("Nothing to clean");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(values) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
|
||||||
|
let errors = false;
|
||||||
|
for (const val of values || []) {
|
||||||
|
if (val.status == "rejected") {
|
||||||
|
messageManager.error("Fail to delete backup", val.reason as string);
|
||||||
|
logger.error("Fail to delete backup");
|
||||||
|
logger.error(val.reason);
|
||||||
|
errors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors) {
|
||||||
|
messageManager.error("Fail to clean backups in Home Assistant");
|
||||||
|
logger.error("Fail to clean backups in Home Assistant");
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
logger.error("Fail to clean Home Assistant backup", reason.message);
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to clean Home Assistant backup",
|
||||||
|
reason.message
|
||||||
|
);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadSnapshot(path: string) {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_UPLOAD_HA;
|
||||||
|
status.progress = 0;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.info("Uploading backup...");
|
||||||
|
const stream = fs.createReadStream(path);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", stream);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: form,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
};
|
||||||
|
return got
|
||||||
|
.post(`http://hassio/backups/new/upload`, options)
|
||||||
|
.on("uploadProgress", (e: Progress) => {
|
||||||
|
const percent = e.percent;
|
||||||
|
if (status.progress !== percent) {
|
||||||
|
status.progress = percent;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
}
|
||||||
|
if (percent >= 1) {
|
||||||
|
logger.info("Upload done...");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(res) => {
|
||||||
|
logger.info(`...Upload finish ! (status: ${res.statusCode})`);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
fs.unlinkSync(path);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
(err: RequestError) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
fs.unlinkSync(path);
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to upload backup to Home Assistant",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
logger.error("Fail to upload backup to Home Assistant");
|
||||||
|
logger.error(err);
|
||||||
|
logger.error(`Body: ${err.response?.body as string}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAddons(addonSlugs: string[]) {
|
||||||
|
logger.info("Stopping addons...");
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.STOP_ADDON;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
const promises = [];
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
};
|
||||||
|
for (const addon of addonSlugs) {
|
||||||
|
if (addon !== "") {
|
||||||
|
logger.debug(`... Stopping addon ${addon}`);
|
||||||
|
promises.push(got.post(`http://hassio/addons/${addon}/stop`, option));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.allSettled(promises).then((values) => {
|
||||||
|
let errors = false;
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
for (const val of values) {
|
||||||
|
if (val.status == "rejected") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
messageManager.error("Fail to stop addon", val.reason);
|
||||||
|
logger.error("Fail to stop addon");
|
||||||
|
logger.error(val.reason);
|
||||||
|
errors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors) {
|
||||||
|
messageManager.error("Fail to stop addon");
|
||||||
|
logger.error("Fail to stop addon");
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddons(addonSlugs: string[]) {
|
||||||
|
logger.info("Starting addons...");
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.START_ADDON;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
const promises = [];
|
||||||
|
const option: OptionsOfJSONResponseBody = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
responseType: "json",
|
||||||
|
};
|
||||||
|
for (const addon of addonSlugs) {
|
||||||
|
if (addon !== "") {
|
||||||
|
logger.debug(`... Starting addon ${addon}`);
|
||||||
|
promises.push(got.post(`http://hassio/addons/${addon}/start`, option));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.allSettled(promises).then((values) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
let errors = false;
|
||||||
|
for (const val of values) {
|
||||||
|
if (val.status == "rejected") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
messageManager.error("Fail to start addon", val.reason);
|
||||||
|
logger.error("Fail to start addon");
|
||||||
|
logger.error(val.reason);
|
||||||
|
errors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors) {
|
||||||
|
messageManager.error("Fail to start addon");
|
||||||
|
logger.error("Fail to start addon");
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderList() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Home Assistant configuration",
|
||||||
|
slug: "homeassistant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSL",
|
||||||
|
slug: "ssl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Share",
|
||||||
|
slug: "share",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Media",
|
||||||
|
slug: "media",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Local add-ons",
|
||||||
|
slug: "addons/local",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish_state() {
|
||||||
|
// let data_error_sensor = {
|
||||||
|
// state: state.status == "error" ? "on" : "off",
|
||||||
|
// attributes: {
|
||||||
|
// friendly_name: "Nexcloud Backup Error",
|
||||||
|
// device_class: "problem",
|
||||||
|
// error_code: state.error_code,
|
||||||
|
// message: state.message,
|
||||||
|
// icon: state.status == "error" ? "mdi:cloud-alert" : "mdi:cloud-check"
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// let option = {
|
||||||
|
// headers: { "authorization": `Bearer ${token}` },
|
||||||
|
// responseType: "json",
|
||||||
|
// json: data_error_sensor
|
||||||
|
// };
|
||||||
|
// got.post(`http://hassio/core/api/states/binary_sensor.nextcloud_backup_error`, option)
|
||||||
|
// .then((result) => {
|
||||||
|
// logger.debug('Home assistant sensor updated (error status)');
|
||||||
|
// })
|
||||||
|
// .catch((error) => {
|
||||||
|
// logger.error(error);
|
||||||
|
// });
|
||||||
|
// let icon = ""
|
||||||
|
// switch(state.status){
|
||||||
|
// case "error":
|
||||||
|
// icon = "mdi:cloud-alert";
|
||||||
|
// break;
|
||||||
|
// case "download":
|
||||||
|
// case "download-b":
|
||||||
|
// icon = "mdi:cloud-download";
|
||||||
|
// break;
|
||||||
|
// case "upload":
|
||||||
|
// case "upload-b":
|
||||||
|
// icon = "mdi:cloud-upload";
|
||||||
|
// break;
|
||||||
|
// case "idle":
|
||||||
|
// icon = "mdi:cloud-check";
|
||||||
|
// break;
|
||||||
|
// default:
|
||||||
|
// icon = "mdi:cloud-sync";
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// let data_state_sensor = {
|
||||||
|
// state: state.status,
|
||||||
|
// attributes: {
|
||||||
|
// friendly_name: "Nexcloud Backup Status",
|
||||||
|
// error_code: state.error_code,
|
||||||
|
// message: state.message,
|
||||||
|
// icon: icon,
|
||||||
|
// last_backup: state.last_backup == null || state.last_backup == "" ? "" : new Date(state.last_backup).toISOString(),
|
||||||
|
// next_backup: state.next_backup == null || state.next_backup == "" ? "" : new Date(state.next_backup).toISOString()
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// option.json = data_state_sensor
|
||||||
|
// got.post(`http://hassio/core/api/states/sensor.nextcloud_backup_status`, option)
|
||||||
|
// .then((result) => {
|
||||||
|
// logger.debug('Home assistant sensor updated (status)');
|
||||||
|
// })
|
||||||
|
// .catch((error) => {
|
||||||
|
// logger.error(error);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
clean,
|
||||||
|
createNewBackup,
|
||||||
|
delSnap,
|
||||||
|
downloadSnapshot,
|
||||||
|
getAddonList,
|
||||||
|
getBackupInfo,
|
||||||
|
getBackups,
|
||||||
|
getVersion,
|
||||||
|
publish_state,
|
||||||
|
startAddons,
|
||||||
|
stopAddons,
|
||||||
|
uploadSnapshot,
|
||||||
|
};
|
228
nextcloud_backup/backend/src/services/orchestrator.ts
Normal file
228
nextcloud_backup/backend/src/services/orchestrator.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { unlinkSync } from "fs";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import messageManager from "../tools/messageManager.js";
|
||||||
|
import * as statusTools from "../tools/status.js";
|
||||||
|
import { BackupType } from "../types/services/backupConfig.js";
|
||||||
|
import type {
|
||||||
|
AddonModel,
|
||||||
|
BackupDetailModel,
|
||||||
|
BackupUpload,
|
||||||
|
SupervisorResponse,
|
||||||
|
} from "../types/services/ha_os_response.js";
|
||||||
|
import { WorkflowType } from "../types/services/orchecstrator.js";
|
||||||
|
import * as backupConfigService from "./backupConfigService.js";
|
||||||
|
import * as homeAssistantService from "./homeAssistantService.js";
|
||||||
|
import { getBackupFolder, getWebdavConfig } from "./webdavConfigService.js";
|
||||||
|
import * as webDavService from "./webdavService.js";
|
||||||
|
|
||||||
|
export function doBackupWorkflow(type: WorkflowType) {
|
||||||
|
let name = "";
|
||||||
|
let addonsToStartStop = [] as string[];
|
||||||
|
let addonInHa = [] as AddonModel[];
|
||||||
|
let tmpBackupFile = "";
|
||||||
|
|
||||||
|
const backupConfig = backupConfigService.getBackupConfig();
|
||||||
|
const webdavConfig = getWebdavConfig();
|
||||||
|
|
||||||
|
return homeAssistantService
|
||||||
|
.getVersion()
|
||||||
|
.then((value) => {
|
||||||
|
const version = value.body.data.version;
|
||||||
|
name = backupConfigService.getFormatedName(type, version);
|
||||||
|
return homeAssistantService.getAddonList();
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
addonInHa = response.body.data.addons;
|
||||||
|
addonsToStartStop = sanitizeAddonList(
|
||||||
|
backupConfig.autoStopAddon,
|
||||||
|
response.body.data.addons
|
||||||
|
);
|
||||||
|
return webDavService.checkWebdavLogin(webdavConfig);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return homeAssistantService.stopAddons(addonsToStartStop);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (backupConfig.backupType == BackupType.FULL) {
|
||||||
|
return homeAssistantService.createNewBackup(
|
||||||
|
name,
|
||||||
|
backupConfig.backupType,
|
||||||
|
backupConfig.password.enabled,
|
||||||
|
backupConfig.password.value
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const addons = getAddonToBackup(
|
||||||
|
backupConfig.exclude?.addon as string[],
|
||||||
|
addonInHa
|
||||||
|
);
|
||||||
|
const folders = getFolderToBackup(
|
||||||
|
backupConfig.exclude?.folder as string[],
|
||||||
|
homeAssistantService.getFolderList()
|
||||||
|
);
|
||||||
|
return homeAssistantService.createNewBackup(
|
||||||
|
name,
|
||||||
|
backupConfig.backupType,
|
||||||
|
backupConfig.password.enabled,
|
||||||
|
backupConfig.password.value,
|
||||||
|
addons,
|
||||||
|
folders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
return homeAssistantService.downloadSnapshot(response.body.data.slug);
|
||||||
|
})
|
||||||
|
.then((tmpFile) => {
|
||||||
|
tmpBackupFile = tmpFile;
|
||||||
|
if (webdavConfig.chunckedUpload) {
|
||||||
|
return webDavService.chunkedUpload(
|
||||||
|
tmpFile,
|
||||||
|
getBackupFolder(type, webdavConfig) + name + ".tar",
|
||||||
|
webdavConfig
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return webDavService.webdavUploadFile(
|
||||||
|
tmpFile,
|
||||||
|
getBackupFolder(type, webdavConfig) + name + ".tar",
|
||||||
|
webdavConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return homeAssistantService.startAddons(addonsToStartStop);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return homeAssistantService.clean(backupConfig);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return webDavService.clean(backupConfig, webdavConfig);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info("Backup workflow finished successfully !");
|
||||||
|
messageManager.info(
|
||||||
|
"Backup workflow finished successfully !",
|
||||||
|
`name: ${name}`
|
||||||
|
);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.last_backup.success = true;
|
||||||
|
status.last_backup.last_try = DateTime.now();
|
||||||
|
status.last_backup.last_success = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
backupFail();
|
||||||
|
if (tmpBackupFile != "") {
|
||||||
|
unlinkSync(tmpBackupFile);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadToCloud(slug: string) {
|
||||||
|
const webdavConfig = getWebdavConfig();
|
||||||
|
let tmpBackupFile = "";
|
||||||
|
let backupInfo = {} as BackupDetailModel;
|
||||||
|
|
||||||
|
return webDavService
|
||||||
|
.checkWebdavLogin(webdavConfig)
|
||||||
|
.then(() => {
|
||||||
|
return homeAssistantService.getBackupInfo(slug);
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
backupInfo = response.body.data;
|
||||||
|
return homeAssistantService.downloadSnapshot(slug);
|
||||||
|
})
|
||||||
|
.then((tmpFile) => {
|
||||||
|
tmpBackupFile = tmpFile;
|
||||||
|
if (webdavConfig.chunckedUpload) {
|
||||||
|
return webDavService.chunkedUpload(
|
||||||
|
tmpFile,
|
||||||
|
getBackupFolder(WorkflowType.MANUAL, webdavConfig) +
|
||||||
|
backupInfo.name +
|
||||||
|
".tar",
|
||||||
|
webdavConfig
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return webDavService.webdavUploadFile(
|
||||||
|
tmpFile,
|
||||||
|
getBackupFolder(WorkflowType.MANUAL, webdavConfig) +
|
||||||
|
backupInfo.name +
|
||||||
|
".tar",
|
||||||
|
webdavConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info(`Successfully uploaded ${backupInfo.name} to cloud.`);
|
||||||
|
messageManager.info(
|
||||||
|
"Successfully uploaded backup to cloud.",
|
||||||
|
`Name: ${backupInfo.name}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (tmpBackupFile != "") {
|
||||||
|
unlinkSync(tmpBackupFile);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This methods remove addon that are no installed in HA from the conf array
|
||||||
|
function sanitizeAddonList(addonInConf: string[], addonInHA: AddonModel[]) {
|
||||||
|
return addonInConf.filter((value) => addonInHA.some((v) => v.slug == value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonToBackup(excludedAddon: string[], addonInHA: AddonModel[]) {
|
||||||
|
const slugs: string[] = [];
|
||||||
|
for (const addon of addonInHA) {
|
||||||
|
if (!excludedAddon.includes(addon.slug)) slugs.push(addon.slug);
|
||||||
|
}
|
||||||
|
logger.debug("Addon to backup:");
|
||||||
|
logger.debug(slugs);
|
||||||
|
return slugs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFolderToBackup(
|
||||||
|
excludedFolder: string[],
|
||||||
|
folderInHA: { name: string; slug: string }[]
|
||||||
|
) {
|
||||||
|
const slugs = [];
|
||||||
|
for (const folder of folderInHA) {
|
||||||
|
if (!excludedFolder.includes(folder.slug)) slugs.push(folder.slug);
|
||||||
|
}
|
||||||
|
logger.debug("Folders to backup:");
|
||||||
|
logger.debug(slugs);
|
||||||
|
return slugs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupFail() {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.last_backup.success = false;
|
||||||
|
status.last_backup.last_try = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
messageManager.error("Last backup as failed !");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreToHA(webdavPath: string, filename: string) {
|
||||||
|
const webdavConfig = getWebdavConfig();
|
||||||
|
return webDavService
|
||||||
|
.checkWebdavLogin(webdavConfig)
|
||||||
|
.then(() => {
|
||||||
|
return webDavService.downloadFile(webdavPath, filename, webdavConfig);
|
||||||
|
})
|
||||||
|
.then((tmpFile) => {
|
||||||
|
return homeAssistantService.uploadSnapshot(tmpFile);
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res) {
|
||||||
|
const body = JSON.parse(res.body) as SupervisorResponse<BackupUpload>;
|
||||||
|
logger.info(`Successfully uploaded ${filename} to Home Assistant.`);
|
||||||
|
messageManager.info(
|
||||||
|
"Successfully uploaded backup to Home Assistant.",
|
||||||
|
`Name: ${filename} slug: ${body.data.slug}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
108
nextcloud_backup/backend/src/services/webdavConfigService.ts
Normal file
108
nextcloud_backup/backend/src/services/webdavConfigService.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import Joi from "joi";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import * as pathTools from "../tools/pathTools.js";
|
||||||
|
import { default_root } from "../tools/pathTools.js";
|
||||||
|
import { WorkflowType } from "../types/services/orchecstrator.js";
|
||||||
|
import {
|
||||||
|
type WebdavConfig,
|
||||||
|
WebdavEndpointType,
|
||||||
|
} from "../types/services/webdavConfig.js";
|
||||||
|
import WebdavConfigValidation from "../types/services/webdavConfigValidation.js";
|
||||||
|
|
||||||
|
const webdavConfigPath = "/data/webdavConfigV2.json";
|
||||||
|
const NEXTCLOUD_ENDPOINT = "/remote.php/dav/files/$username";
|
||||||
|
const NEXTCLOUD_CHUNK_ENDPOINT = "/remote.php/dav/uploads/$username";
|
||||||
|
|
||||||
|
export function validateWebdavConfig(config: WebdavConfig) {
|
||||||
|
const validator = Joi.object(WebdavConfigValidation);
|
||||||
|
return validator.validateAsync(config, {
|
||||||
|
abortEarly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWebdavConfig(config: WebdavConfig) {
|
||||||
|
fs.writeFileSync(webdavConfigPath, JSON.stringify(config, undefined, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebdavConfig(): WebdavConfig {
|
||||||
|
if (!fs.existsSync(webdavConfigPath)) {
|
||||||
|
logger.warn("Webdav Config file not found, creating default one !");
|
||||||
|
const defaultConfig = getWebdavDefaultConfig();
|
||||||
|
saveWebdavConfig(defaultConfig);
|
||||||
|
return defaultConfig;
|
||||||
|
} else {
|
||||||
|
return JSON.parse(
|
||||||
|
fs.readFileSync(webdavConfigPath).toString()
|
||||||
|
) as WebdavConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndpoint(config: WebdavConfig) {
|
||||||
|
let endpoint: string;
|
||||||
|
|
||||||
|
if (config.webdavEndpoint.type == WebdavEndpointType.NEXTCLOUD) {
|
||||||
|
endpoint = NEXTCLOUD_ENDPOINT.replace("$username", config.username);
|
||||||
|
} else if (config.webdavEndpoint.customEndpoint) {
|
||||||
|
endpoint = config.webdavEndpoint.customEndpoint.replace(
|
||||||
|
"$username",
|
||||||
|
config.username
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!endpoint.startsWith("/")) {
|
||||||
|
endpoint = "/" + endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint.endsWith("/")) {
|
||||||
|
return endpoint + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChunkEndpoint(config: WebdavConfig) {
|
||||||
|
let endpoint: string;
|
||||||
|
|
||||||
|
if (config.webdavEndpoint.type == WebdavEndpointType.NEXTCLOUD) {
|
||||||
|
endpoint = NEXTCLOUD_CHUNK_ENDPOINT.replace("$username", config.username);
|
||||||
|
} else if (config.webdavEndpoint.customChunkEndpoint) {
|
||||||
|
endpoint = config.webdavEndpoint.customChunkEndpoint.replace(
|
||||||
|
"$username",
|
||||||
|
config.username
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!endpoint.startsWith("/")) {
|
||||||
|
endpoint = "/" + endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint.endsWith("/")) {
|
||||||
|
return endpoint + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupFolder(type: WorkflowType, config: WebdavConfig) {
|
||||||
|
const end = type == WorkflowType.AUTO ? pathTools.auto : pathTools.manual;
|
||||||
|
return config.backupDir.endsWith("/")
|
||||||
|
? config.backupDir + end
|
||||||
|
: config.backupDir + "/" + end;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebdavDefaultConfig(): WebdavConfig {
|
||||||
|
return {
|
||||||
|
url: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
backupDir: default_root,
|
||||||
|
allowSelfSignedCerts: false,
|
||||||
|
chunckedUpload: false,
|
||||||
|
webdavEndpoint: {
|
||||||
|
type: WebdavEndpointType.NEXTCLOUD,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
660
nextcloud_backup/backend/src/services/webdavService.ts
Normal file
660
nextcloud_backup/backend/src/services/webdavService.ts
Normal file
@ -0,0 +1,660 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import fs from "fs";
|
||||||
|
import got, {
|
||||||
|
HTTPError,
|
||||||
|
RequestError,
|
||||||
|
type Method,
|
||||||
|
type PlainResponse,
|
||||||
|
} from "got";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import logger from "../config/winston.js";
|
||||||
|
import messageManager from "../tools/messageManager.js";
|
||||||
|
import * as pathTools from "../tools/pathTools.js";
|
||||||
|
import * as statusTools from "../tools/status.js";
|
||||||
|
import type { WebdavBackup } from "../types/services/webdav.js";
|
||||||
|
import type { WebdavConfig } from "../types/services/webdavConfig.js";
|
||||||
|
import { States } from "../types/status.js";
|
||||||
|
import { templateToRegexp } from "./backupConfigService.js";
|
||||||
|
import { getChunkEndpoint, getEndpoint } from "./webdavConfigService.js";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
|
import { humanFileSize } from "../tools/toolbox.js";
|
||||||
|
import type { BackupConfig } from "../types/services/backupConfig.js";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MiB Same as desktop client
|
||||||
|
const CHUNK_NUMBER_SIZE = 5; // To add landing "0"
|
||||||
|
const PROPFIND_BODY =
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>\
|
||||||
|
<d:propfind xmlns:d="DAV:">\
|
||||||
|
<d:prop>\
|
||||||
|
<d:getlastmodified />\
|
||||||
|
<d:getetag />\
|
||||||
|
<d:getcontenttype />\
|
||||||
|
<d:resourcetype />\
|
||||||
|
<d:getcontentlength />\
|
||||||
|
</d:prop>\
|
||||||
|
</d:propfind>';
|
||||||
|
|
||||||
|
export function checkWebdavLogin(
|
||||||
|
config: WebdavConfig,
|
||||||
|
silent: boolean = false
|
||||||
|
) {
|
||||||
|
const endpoint = getEndpoint(config);
|
||||||
|
return got(config.url + endpoint, {
|
||||||
|
method: "OPTIONS",
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
}).then(
|
||||||
|
(response) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.webdav.logged_in = true;
|
||||||
|
status.webdav.last_check = DateTime.now();
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
if (!silent) {
|
||||||
|
messageManager.error("Fail to connect to Webdav", reason.message);
|
||||||
|
}
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.webdav = {
|
||||||
|
logged_in: false,
|
||||||
|
folder_created: status.webdav.folder_created,
|
||||||
|
last_check: DateTime.now(),
|
||||||
|
};
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.error(`Fail to connect to Webdav`);
|
||||||
|
logger.error(reason);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBackupFolder(conf: WebdavConfig) {
|
||||||
|
const root_splited = conf.backupDir.split("/").splice(1);
|
||||||
|
let path = "/";
|
||||||
|
for (const elem of root_splited) {
|
||||||
|
if (elem != "") {
|
||||||
|
path = path + elem + "/";
|
||||||
|
try {
|
||||||
|
await createDirectory(path, conf);
|
||||||
|
logger.debug(`Path ${path} created.`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HTTPError && error.response.statusCode == 405)
|
||||||
|
logger.debug(`Path ${path} already exist.`);
|
||||||
|
else {
|
||||||
|
messageManager.error("Fail to create webdav root folder");
|
||||||
|
logger.error("Fail to create webdav root folder");
|
||||||
|
logger.error(error);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.webdav.folder_created = false;
|
||||||
|
status.webdav.last_check = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return Promise.reject(error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const elem of [pathTools.auto, pathTools.manual]) {
|
||||||
|
try {
|
||||||
|
await createDirectory(conf.backupDir + elem, conf);
|
||||||
|
logger.debug(`Path ${conf.backupDir + elem} created.`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HTTPError && error.response.statusCode == 405) {
|
||||||
|
logger.debug(`Path ${conf.backupDir + elem} already exist.`);
|
||||||
|
} else {
|
||||||
|
messageManager.error("Fail to create webdav root folder");
|
||||||
|
logger.error("Fail to create webdav root folder");
|
||||||
|
logger.error(error);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.webdav.folder_created = false;
|
||||||
|
status.webdav.last_check = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return Promise.reject(error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.webdav.folder_created = true;
|
||||||
|
status.webdav.last_check = DateTime.now();
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDirectory(path: string, config: WebdavConfig) {
|
||||||
|
const endpoint = getEndpoint(config);
|
||||||
|
return got(config.url + endpoint + path, {
|
||||||
|
method: "MKCOL" as Method,
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackups(
|
||||||
|
folder: string,
|
||||||
|
config: WebdavConfig,
|
||||||
|
nameTemplate: string
|
||||||
|
) {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
if (!status.webdav.logged_in && !status.webdav.folder_created) {
|
||||||
|
return Promise.reject(new Error("Not logged in"));
|
||||||
|
}
|
||||||
|
const endpoint = getEndpoint(config);
|
||||||
|
return got(config.url + endpoint + config.backupDir + folder, {
|
||||||
|
method: "PROPFIND" as Method,
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
Depth: "1",
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
body: PROPFIND_BODY,
|
||||||
|
}).then(
|
||||||
|
(value) => {
|
||||||
|
const data = parseXmlBackupData(value.body, config).sort(
|
||||||
|
(a, b) => b.lastEdit.toMillis() - a.lastEdit.toMillis()
|
||||||
|
);
|
||||||
|
return extractBackupInfo(data, nameTemplate);
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
messageManager.error(
|
||||||
|
`Fail to retrive webdav backups in ${folder} folder`
|
||||||
|
);
|
||||||
|
logger.error(`Fail to retrive webdav backups in ${folder} folder`);
|
||||||
|
logger.error(reason);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBackupInfo(backups: WebdavBackup[], template: string) {
|
||||||
|
const regex = new RegExp(templateToRegexp(template));
|
||||||
|
for (const elem of backups) {
|
||||||
|
const match = elem.name.match(regex);
|
||||||
|
if (match?.groups?.date) {
|
||||||
|
let format = "yyyy-LL-dd";
|
||||||
|
let date = match.groups.date;
|
||||||
|
if (match.groups.hour) {
|
||||||
|
format += "+HHmm";
|
||||||
|
date += `+${match.groups.hour}`;
|
||||||
|
} else if (match.groups.hour_12) {
|
||||||
|
format += "+hhmma";
|
||||||
|
date += `+${match.groups.hour_12}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
elem.creationDate = DateTime.fromFormat(date, format);
|
||||||
|
}
|
||||||
|
if (match?.groups?.version) {
|
||||||
|
elem.haVersion = match.groups.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBackup(path: string, config: WebdavConfig) {
|
||||||
|
logger.debug(`Deleting Cloud backup ${path}`);
|
||||||
|
const endpoint = getEndpoint(config);
|
||||||
|
return got
|
||||||
|
.delete(config.url + endpoint + path, {
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString(
|
||||||
|
"base64"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
messageManager.error("Fail to delete backup in webdav", reason.message);
|
||||||
|
logger.error(`Fail to delete backup in Cloud`);
|
||||||
|
logger.error(reason);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmlBackupData(body: string, config: WebdavConfig) {
|
||||||
|
const parser = new XMLParser();
|
||||||
|
const data = parser.parse(body);
|
||||||
|
const multistatus = data["d:multistatus"];
|
||||||
|
const backups: WebdavBackup[] = [];
|
||||||
|
if (Array.isArray(multistatus["d:response"])) {
|
||||||
|
for (const elem of multistatus["d:response"]) {
|
||||||
|
// If array -> base folder, ignoring it
|
||||||
|
if (!Array.isArray(elem["d:propstat"])) {
|
||||||
|
const propstat = elem["d:propstat"];
|
||||||
|
const id = propstat["d:prop"]["d:getetag"].replaceAll('"', "");
|
||||||
|
const href = decodeURI(elem["d:href"]);
|
||||||
|
const name = href.split("/").slice(-1)[0];
|
||||||
|
const lastEdit = DateTime.fromHTTP(
|
||||||
|
propstat["d:prop"]["d:getlastmodified"]
|
||||||
|
);
|
||||||
|
backups.push({
|
||||||
|
id: id,
|
||||||
|
lastEdit: lastEdit,
|
||||||
|
size: propstat["d:prop"]["d:getcontentlength"],
|
||||||
|
name: name,
|
||||||
|
path: href.replace(getEndpoint(config), ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webdavUploadFile(
|
||||||
|
localPath: string,
|
||||||
|
webdavPath: string,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
logger.info(`Uploading ${localPath} to webdav...`);
|
||||||
|
|
||||||
|
const stats = fs.statSync(localPath);
|
||||||
|
const stream = fs.createReadStream(localPath);
|
||||||
|
const options = {
|
||||||
|
body: stream,
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString(
|
||||||
|
"base64"
|
||||||
|
),
|
||||||
|
"content-length": String(stats.size),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
};
|
||||||
|
const url = config.url + getEndpoint(config) + webdavPath;
|
||||||
|
|
||||||
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
|
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_UPLOAD_CLOUD;
|
||||||
|
status.progress = 0;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
got.stream
|
||||||
|
.put(encodeURI(url), options)
|
||||||
|
.on("uploadProgress", (e) => {
|
||||||
|
const percent = e.percent;
|
||||||
|
if (status.progress !== percent) {
|
||||||
|
status.progress = percent;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
}
|
||||||
|
if (percent >= 1) {
|
||||||
|
logger.info("Upload done...");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("response", (res: PlainResponse) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
if (res.statusCode != 201 && res.statusCode != 204) {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to upload file to Cloud.",
|
||||||
|
`Code: ${res.statusCode} Body: ${res.body as string}`
|
||||||
|
);
|
||||||
|
logger.error(`Fail to upload file to Cloud`);
|
||||||
|
logger.error(`Code: ${res.statusCode}`);
|
||||||
|
logger.error(`Body: ${res.body as string}`);
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
reject(new Error(res.statusCode.toString()));
|
||||||
|
} else {
|
||||||
|
logger.info(`...Upload finish ! (status: ${res.statusCode})`);
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("error", (err: RequestError) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
messageManager.error("Fail to upload backup to Cloud", err.message);
|
||||||
|
logger.error("Fail to upload backup to Cloud");
|
||||||
|
logger.error(err);
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chunkedUpload(
|
||||||
|
localPath: string,
|
||||||
|
webdavPath: string,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const fileSize = fs.statSync(localPath).size;
|
||||||
|
|
||||||
|
const chunkEndpoint = getChunkEndpoint(config);
|
||||||
|
const chunkedUrl = config.url + chunkEndpoint + uuid;
|
||||||
|
const finalDestination = config.url + getEndpoint(config) + webdavPath;
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_UPLOAD_CLOUD;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
try {
|
||||||
|
await initChunkedUpload(chunkedUrl, finalDestination, config);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to init chuncked upload.",
|
||||||
|
`Code: ${err.code} Body: ${err.response?.body}`
|
||||||
|
);
|
||||||
|
logger.error(`Fail to init chuncked upload`);
|
||||||
|
logger.error(`Code: ${err.code}`);
|
||||||
|
logger.error(`Body: ${err.response?.body}`);
|
||||||
|
} else {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to init chuncked upload.",
|
||||||
|
(err as Error).message
|
||||||
|
);
|
||||||
|
logger.error(`Fail to init chuncked upload`);
|
||||||
|
logger.error((err as Error).message);
|
||||||
|
}
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = Math.min(CHUNK_SIZE - 1, fileSize - 1);
|
||||||
|
|
||||||
|
let current_size = end + 1;
|
||||||
|
// const uploadedBytes = 0;
|
||||||
|
|
||||||
|
let i = 1;
|
||||||
|
while (start < fileSize - 1) {
|
||||||
|
const chunk = fs.createReadStream(localPath, { start, end });
|
||||||
|
try {
|
||||||
|
const chunckNumber = i.toString().padStart(CHUNK_NUMBER_SIZE, "0");
|
||||||
|
await uploadChunk(
|
||||||
|
chunkedUrl + `/${chunckNumber}`,
|
||||||
|
finalDestination,
|
||||||
|
chunk,
|
||||||
|
current_size,
|
||||||
|
fileSize,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
start = end + 1;
|
||||||
|
end = Math.min(start + CHUNK_SIZE - 1, fileSize - 1);
|
||||||
|
current_size = end - start + 1;
|
||||||
|
i++;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to upload file to Cloud.",
|
||||||
|
`Error: ${error.message}`
|
||||||
|
);
|
||||||
|
logger.error(`Fail to upload file to Cloud`);
|
||||||
|
} else {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to upload file to Cloud.",
|
||||||
|
`Code: ${(error as PlainResponse).statusCode} Body: ${
|
||||||
|
(error as PlainResponse).body as string
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
logger.error(`Fail to upload file to Cloud`);
|
||||||
|
logger.error(`Code: ${(error as PlainResponse).statusCode}`);
|
||||||
|
logger.error(`Body: ${(error as PlainResponse).body as string}`);
|
||||||
|
}
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug("Chunked upload funished, assembling chunks.");
|
||||||
|
try {
|
||||||
|
await assembleChunkedUpload(chunkedUrl, finalDestination, fileSize, config);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.info(`...Upload finish !`);
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
messageManager.error(
|
||||||
|
"Fail to assembling chunks.",
|
||||||
|
`Code: ${err.code} Body: ${err.response?.body}`
|
||||||
|
);
|
||||||
|
logger.error("Fail to assemble chunks");
|
||||||
|
logger.error(`Code: ${err.code}`);
|
||||||
|
logger.error(`Body: ${err.response?.body}`);
|
||||||
|
} else {
|
||||||
|
messageManager.error("Fail to assemble chunks", (err as Error).message);
|
||||||
|
logger.error("Fail to assemble chunks");
|
||||||
|
logger.error((err as Error).message);
|
||||||
|
}
|
||||||
|
fs.unlinkSync(localPath);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadChunk(
|
||||||
|
url: string,
|
||||||
|
finalDestination: string,
|
||||||
|
body: fs.ReadStream,
|
||||||
|
contentLength: number,
|
||||||
|
totalLength: number,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
return new Promise<PlainResponse>((resolve, reject) => {
|
||||||
|
logger.debug(`Uploading chunck.`);
|
||||||
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
|
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
|
||||||
|
logger.debug(`...Chunk size: ${contentLength}`);
|
||||||
|
logger.debug(`...Total size: ${totalLength}`);
|
||||||
|
got.stream
|
||||||
|
.put(url, {
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString(
|
||||||
|
"base64"
|
||||||
|
),
|
||||||
|
Destination: encodeURI(finalDestination),
|
||||||
|
"OC-Total-Length": totalLength.toString(),
|
||||||
|
"content-length": contentLength.toString(),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
body: body,
|
||||||
|
})
|
||||||
|
.on("response", (res: PlainResponse) => {
|
||||||
|
if (res.ok) {
|
||||||
|
logger.debug("Chunk upload done.");
|
||||||
|
resolve(res);
|
||||||
|
} else {
|
||||||
|
logger.error(`Fail to upload chunk: ${res.statusCode}`);
|
||||||
|
reject(new Error(res.statusCode.toString()));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
logger.error(`Fail to upload chunk: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChunkedUpload(
|
||||||
|
url: string,
|
||||||
|
finalDestination: string,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
logger.info(`Init chuncked upload.`);
|
||||||
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
|
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
|
||||||
|
return got(encodeURI(url), {
|
||||||
|
method: "MKCOL" as Method,
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
Destination: encodeURI(finalDestination),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleChunkedUpload(
|
||||||
|
url: string,
|
||||||
|
finalDestination: string,
|
||||||
|
totalLength: number,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
const chunckFile = `${url}/.file`;
|
||||||
|
logger.info(`Assemble chuncked upload.`);
|
||||||
|
logger.debug(`...URI: ${encodeURI(chunckFile)}`);
|
||||||
|
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
|
||||||
|
return got(encodeURI(chunckFile), {
|
||||||
|
method: "MOVE" as Method,
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
Destination: encodeURI(finalDestination),
|
||||||
|
"OC-Total-Length": totalLength.toString(),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFile(
|
||||||
|
webdavPath: string,
|
||||||
|
filename: string,
|
||||||
|
config: WebdavConfig
|
||||||
|
) {
|
||||||
|
logger.info(`Downloading ${webdavPath} from webdav...`);
|
||||||
|
if (!fs.existsSync("./temp/")) {
|
||||||
|
fs.mkdirSync("./temp/");
|
||||||
|
}
|
||||||
|
const tmp_file = `./temp/${filename}`;
|
||||||
|
const stream = fs.createWriteStream(tmp_file);
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(config.username + ":" + config.password).toString("base64"),
|
||||||
|
},
|
||||||
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
|
};
|
||||||
|
const url = config.url + getEndpoint(config) + webdavPath;
|
||||||
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
|
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.BKUP_DOWNLOAD_CLOUD;
|
||||||
|
status.progress = 0;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return pipeline(
|
||||||
|
got.stream.get(encodeURI(url), options).on("downloadProgress", (e) => {
|
||||||
|
const percent = Math.round(e.percent * 100) / 100;
|
||||||
|
if (status.progress !== percent) {
|
||||||
|
status.progress = percent;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
stream
|
||||||
|
).then(
|
||||||
|
() => {
|
||||||
|
logger.info("Download success !");
|
||||||
|
status.progress = 1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
logger.debug(
|
||||||
|
`Backup dl size: ${humanFileSize(fs.statSync(tmp_file).size)}`
|
||||||
|
);
|
||||||
|
return tmp_file;
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
if (fs.existsSync(tmp_file)) fs.unlinkSync(tmp_file);
|
||||||
|
logger.error("Fail to download Cloud backup", reason.message);
|
||||||
|
messageManager.error("Fail to download Cloud backup", reason.message);
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clean(backupConfig: BackupConfig, webdavConfig: WebdavConfig) {
|
||||||
|
if (!backupConfig.autoClean.webdav.enabled) {
|
||||||
|
logger.debug("Clean disabled for Cloud");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
logger.info("Clean for cloud");
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.CLEAN_CLOUD;
|
||||||
|
status.progress = -1;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
const limit = backupConfig.autoClean.homeAssistant.nbrToKeep || 5;
|
||||||
|
return getBackups(pathTools.auto, webdavConfig, backupConfig.nameTemplate)
|
||||||
|
.then((backups) => {
|
||||||
|
if (backups.length > limit) {
|
||||||
|
const toDel = backups.splice(limit);
|
||||||
|
logger.debug(`Number of backup to clean: ${toDel.length}`);
|
||||||
|
const promises = toDel.map((value) =>
|
||||||
|
deleteBackup(value.path, webdavConfig)
|
||||||
|
);
|
||||||
|
return Promise.allSettled(promises);
|
||||||
|
} else {
|
||||||
|
logger.debug("Nothing to clean");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(values) => {
|
||||||
|
const status = statusTools.getStatus();
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
statusTools.setStatus(status);
|
||||||
|
|
||||||
|
let errors = false;
|
||||||
|
for (const val of values || []) {
|
||||||
|
if (val.status == "rejected") {
|
||||||
|
messageManager.error("Fail to delete backup", val.reason);
|
||||||
|
logger.error("Fail to delete backup");
|
||||||
|
logger.error(val.reason);
|
||||||
|
errors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
messageManager.error("Fail to clean backups in Cloud");
|
||||||
|
logger.error("Fail to clean backups in Cloud");
|
||||||
|
return Promise.reject(new Error());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
(reason: RequestError) => {
|
||||||
|
logger.error("Fail to clean cloud backup", reason.message);
|
||||||
|
messageManager.error("Fail to clean cloud backup", reason.message);
|
||||||
|
return Promise.reject(reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
149
nextcloud_backup/backend/src/tools/cronTools.ts
Normal file
149
nextcloud_backup/backend/src/tools/cronTools.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// import { CronJob } from "cron";
|
||||||
|
// import * as settingsTools from "./settingsTools.js";
|
||||||
|
// import * as hassioApiTools from "../services/haOsService.js";
|
||||||
|
// import * as statusTools from "./status.js";
|
||||||
|
// import * as pathTools from "./pathTools.js";
|
||||||
|
// // import webdav from "../services/webdavService.js";
|
||||||
|
|
||||||
|
// import logger from "../config/winston.js";
|
||||||
|
|
||||||
|
// class CronContainer {
|
||||||
|
// cronJob: CronJob | undefined;
|
||||||
|
// cronClean: CronJob | undefined;
|
||||||
|
|
||||||
|
// init() {
|
||||||
|
// const settings = settingsTools.getSettings();
|
||||||
|
// let cronStr = "";
|
||||||
|
// if (!this.cronClean) {
|
||||||
|
// logger.info("Starting auto clean cron...");
|
||||||
|
// this.cronClean = new CronJob(
|
||||||
|
// "0 1 * * *",
|
||||||
|
// this._clean,
|
||||||
|
// null,
|
||||||
|
// false,
|
||||||
|
// Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
// );
|
||||||
|
// this.cronClean.start();
|
||||||
|
// }
|
||||||
|
// if (this.cronJob) {
|
||||||
|
// logger.info("Stopping Cron...");
|
||||||
|
// this.cronJob.stop();
|
||||||
|
// this.cronJob = undefined;
|
||||||
|
// }
|
||||||
|
// if (!settingsTools.check_cron(settingsTools.getSettings())) {
|
||||||
|
// logger.warn("No Cron settings available.");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// switch (settings.cron_base) {
|
||||||
|
// case "0":
|
||||||
|
// logger.warn("No Cron settings available.");
|
||||||
|
// return;
|
||||||
|
// case "1": {
|
||||||
|
// const splited = settings.cron_hour.split(":");
|
||||||
|
// cronStr = "" + splited[1] + " " + splited[0] + " * * *";
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case "2": {
|
||||||
|
// const splited = settings.cron_hour.split(":");
|
||||||
|
// cronStr =
|
||||||
|
// "" + splited[1] + " " + splited[0] + " * * " + settings.cron_weekday;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case "3": {
|
||||||
|
// const splited = settings.cron_hour.split(":");
|
||||||
|
// cronStr =
|
||||||
|
// "" +
|
||||||
|
// splited[1] +
|
||||||
|
// " " +
|
||||||
|
// splited[0] +
|
||||||
|
// " " +
|
||||||
|
// settings.cron_month_day +
|
||||||
|
// " * *";
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case "4": {
|
||||||
|
// cronStr = settings.cron_custom;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// logger.info("Starting Cron...");
|
||||||
|
// this.cronJob = new CronJob(
|
||||||
|
// cronStr,
|
||||||
|
// this._createBackup,
|
||||||
|
// null,
|
||||||
|
// false,
|
||||||
|
// Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
// );
|
||||||
|
// this.cronJob.start();
|
||||||
|
// this.updateNextDate();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// updateNextDate() {
|
||||||
|
// let date;
|
||||||
|
// if (this.cronJob) {
|
||||||
|
// date = this.cronJob
|
||||||
|
// .nextDate()
|
||||||
|
// .setLocale("en")
|
||||||
|
// .toFormat("dd MMM yyyy, HH:mm");
|
||||||
|
// }
|
||||||
|
// const status = statusTools.getStatus();
|
||||||
|
// status.next_backup = date;
|
||||||
|
// statusTools.setStatus(status);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// _createBackup() {
|
||||||
|
// logger.debug("Cron triggered !");
|
||||||
|
// const status = statusTools.getStatus();
|
||||||
|
// if (
|
||||||
|
// status.status === "creating" ||
|
||||||
|
// status.status === "upload" ||
|
||||||
|
// status.status === "download" ||
|
||||||
|
// status.status === "stopping" ||
|
||||||
|
// status.status === "starting"
|
||||||
|
// )
|
||||||
|
// return;
|
||||||
|
// hassioApiTools
|
||||||
|
// .stopAddons()
|
||||||
|
// .then(() => {
|
||||||
|
// hassioApiTools.getVersion().then((version) => {
|
||||||
|
// const name = settingsTools.getFormatedName(false, version);
|
||||||
|
// hassioApiTools.createNewBackup(name).then((id) => {
|
||||||
|
// hassioApiTools.downloadSnapshot(id).then(() => {
|
||||||
|
// webdav
|
||||||
|
// .uploadFile(
|
||||||
|
// id,
|
||||||
|
// webdav.getConf()?.back_dir + pathTools.auto + name + ".tar"
|
||||||
|
// )
|
||||||
|
// .then(() => {
|
||||||
|
// hassioApiTools.startAddons().catch(() => {
|
||||||
|
// // Skip
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
// .catch(() => {
|
||||||
|
// hassioApiTools.startAddons().catch(() => {
|
||||||
|
// // Skip
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// _clean() {
|
||||||
|
// const autoCleanlocal = settingsTools.getSettings().auto_clean_local;
|
||||||
|
// if (autoCleanlocal && autoCleanlocal == "true") {
|
||||||
|
// hassioApiTools.clean().catch();
|
||||||
|
// }
|
||||||
|
// const autoCleanCloud = settingsTools.getSettings().auto_clean_backup;
|
||||||
|
// if (autoCleanCloud && autoCleanCloud == "true") {
|
||||||
|
// webdav.clean().catch();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const INSTANCE = new CronContainer();
|
||||||
|
// export default INSTANCE;
|
69
nextcloud_backup/backend/src/tools/messageManager.ts
Normal file
69
nextcloud_backup/backend/src/tools/messageManager.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { type Message, MessageType } from "../types/message.js";
|
||||||
|
|
||||||
|
const maxMessageLength = 255;
|
||||||
|
|
||||||
|
class MessageManager {
|
||||||
|
private messages: Message[] = [];
|
||||||
|
|
||||||
|
public addMessage(
|
||||||
|
type: MessageType,
|
||||||
|
message: string,
|
||||||
|
detail?: string,
|
||||||
|
isImportant = false
|
||||||
|
) {
|
||||||
|
this.messages.unshift({
|
||||||
|
id: randomUUID(),
|
||||||
|
message: message,
|
||||||
|
type: type,
|
||||||
|
time: DateTime.now(),
|
||||||
|
viewed: !isImportant,
|
||||||
|
detail: detail,
|
||||||
|
});
|
||||||
|
if (this.messages.length > maxMessageLength) {
|
||||||
|
this.messages.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: string, detail?: string) {
|
||||||
|
this.addMessage(MessageType.ERROR, message, detail, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, detail?: string) {
|
||||||
|
this.addMessage(MessageType.WARN, message, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, detail?: string) {
|
||||||
|
this.addMessage(MessageType.INFO, message, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public success(message: string, detail?: string) {
|
||||||
|
this.addMessage(MessageType.SUCCESS, message, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get() {
|
||||||
|
return this.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getById(id: string) {
|
||||||
|
return this.messages.find((value) => value.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public markReaded(id: string) {
|
||||||
|
const index = this.messages.findIndex((value) => value.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.messages[index].viewed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public markAllReaded() {
|
||||||
|
this.messages.forEach((value: Message) => {
|
||||||
|
value.viewed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageManager = new MessageManager();
|
||||||
|
export default messageManager;
|
37
nextcloud_backup/backend/src/tools/status.ts
Normal file
37
nextcloud_backup/backend/src/tools/status.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { States, type Status } from "../types/status.js";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
let status: Status = {
|
||||||
|
status: States.IDLE,
|
||||||
|
last_backup: {},
|
||||||
|
next_backup: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
webdav: {
|
||||||
|
logged_in: false,
|
||||||
|
folder_created: false,
|
||||||
|
last_check: DateTime.now(),
|
||||||
|
},
|
||||||
|
hass: {
|
||||||
|
ok: false,
|
||||||
|
last_check: DateTime.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
if (status.status !== States.IDLE) {
|
||||||
|
status.status = States.IDLE;
|
||||||
|
status.progress = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStatus(new_state: Status) {
|
||||||
|
const old_state_str = JSON.stringify(status);
|
||||||
|
if (old_state_str !== JSON.stringify(new_state)) {
|
||||||
|
status = new_state;
|
||||||
|
// publish_state(status);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
// Found on Stackoverflow, perfect code :D https://stackoverflow.com/a/14919494/8654475
|
// Found on Stackoverflow, perfect code :D https://stackoverflow.com/a/14919494/8654475
|
||||||
function humanFileSize(bytes, si = false, dp = 1) {
|
function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||||
const thresh = si ? 1000 : 1024;
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
if (Math.abs(bytes) < thresh) {
|
if (Math.abs(bytes) < thresh) {
|
18
nextcloud_backup/backend/src/types/message.ts
Normal file
18
nextcloud_backup/backend/src/types/message.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { DateTime } from "luxon";
|
||||||
|
|
||||||
|
export enum MessageType {
|
||||||
|
ERROR = "ERROR",
|
||||||
|
WARN = "WARN",
|
||||||
|
INFO = "INFO",
|
||||||
|
SUCCESS = "SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
time: DateTime;
|
||||||
|
type: MessageType;
|
||||||
|
message: string;
|
||||||
|
viewed: boolean;
|
||||||
|
detail?: string;
|
||||||
|
}
|
57
nextcloud_backup/backend/src/types/services/backupConfig.ts
Normal file
57
nextcloud_backup/backend/src/types/services/backupConfig.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
export enum CronMode {
|
||||||
|
DAILY = "DAILY",
|
||||||
|
WEEKLY = "WEEKLY",
|
||||||
|
MONTHLY = "MONTHLY",
|
||||||
|
CUSTOM = "CUSTOM"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Weekday {
|
||||||
|
SUNDAY,
|
||||||
|
MONDAY,
|
||||||
|
TUESDAY,
|
||||||
|
WEDNESDAY,
|
||||||
|
THURSDAY,
|
||||||
|
FRIDAY,
|
||||||
|
SATURDAY
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BackupType {
|
||||||
|
FULL= "FULL",
|
||||||
|
PARTIAL = "PARTIAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface BackupConfig {
|
||||||
|
nameTemplate: string;
|
||||||
|
cron: CronConfig[];
|
||||||
|
autoClean: {
|
||||||
|
homeAssistant: AutoCleanConfig;
|
||||||
|
webdav: AutoCleanConfig;
|
||||||
|
},
|
||||||
|
backupType: BackupType;
|
||||||
|
exclude?: {
|
||||||
|
addon: string[];
|
||||||
|
folder: string[];
|
||||||
|
}
|
||||||
|
autoStopAddon: string[];
|
||||||
|
password: {
|
||||||
|
enabled: boolean;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface CronConfig {
|
||||||
|
id: string;
|
||||||
|
mode: CronMode;
|
||||||
|
hour?: string;
|
||||||
|
weekday?: Weekday;
|
||||||
|
monthDay: string;
|
||||||
|
custom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface AutoCleanConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
nbrToKeep?: number;
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
import Joi from "joi";
|
||||||
|
import { BackupType, CronMode } from "./backupConfig.js";
|
||||||
|
|
||||||
|
const CronConfigValidation = {
|
||||||
|
id: Joi.string().required().not().empty(),
|
||||||
|
mode: Joi.string()
|
||||||
|
.required()
|
||||||
|
.valid(CronMode.CUSTOM, CronMode.DAILY, CronMode.MONTHLY, CronMode.WEEKLY),
|
||||||
|
hour: Joi.alternatives().conditional("mode", {
|
||||||
|
is: CronMode.CUSTOM,
|
||||||
|
then: Joi.forbidden(),
|
||||||
|
otherwise: Joi.string()
|
||||||
|
.pattern(/^\d{2}:\d{2}$/)
|
||||||
|
.required(),
|
||||||
|
}),
|
||||||
|
weekday: Joi.alternatives().conditional("mode", {
|
||||||
|
is: CronMode.WEEKLY,
|
||||||
|
then: Joi.number().min(0).max(6).required(),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
monthDay: Joi.alternatives().conditional("mode", {
|
||||||
|
is: CronMode.MONTHLY,
|
||||||
|
then: Joi.number().min(1).max(28).required(),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
custom: Joi.alternatives().conditional("mode", {
|
||||||
|
is: CronMode.CUSTOM,
|
||||||
|
then: Joi.string().required(),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const AutoCleanConfig = {
|
||||||
|
enabled: Joi.boolean().required(),
|
||||||
|
nbrToKeep: Joi.alternatives().conditional("enabled", {
|
||||||
|
is: true,
|
||||||
|
then: Joi.number().required().min(0),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupConfigValidation = {
|
||||||
|
nameTemplate: Joi.string().required().not().empty(),
|
||||||
|
cron: Joi.array().items(CronConfigValidation).required(),
|
||||||
|
autoClean: Joi.object({
|
||||||
|
homeAssistant: Joi.object(AutoCleanConfig).required(),
|
||||||
|
webdav: Joi.object(AutoCleanConfig).required(),
|
||||||
|
}).required(),
|
||||||
|
backupType: Joi.string()
|
||||||
|
.required()
|
||||||
|
.valid(BackupType.FULL, BackupType.PARTIAL),
|
||||||
|
exclude: Joi.alternatives().conditional("backupType", {
|
||||||
|
is: BackupType.PARTIAL,
|
||||||
|
then: Joi.object({
|
||||||
|
addon: Joi.array().items(Joi.string().not().empty()).required(),
|
||||||
|
folder: Joi.array().items(Joi.string().not().empty()).required(),
|
||||||
|
}).required(),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
autoStopAddon: Joi.array().items(Joi.string().not().empty()),
|
||||||
|
password: Joi.object({
|
||||||
|
enabled: Joi.boolean().required(),
|
||||||
|
value: Joi.alternatives().conditional("enabled", {
|
||||||
|
is: true,
|
||||||
|
then: Joi.string().required().not().empty(),
|
||||||
|
otherwise: Joi.forbidden(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default backupConfigValidation;
|
@ -0,0 +1,8 @@
|
|||||||
|
export interface NewBackupPayload {
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
homeassistant?: boolean;
|
||||||
|
addons?: string[];
|
||||||
|
folders?: string[];
|
||||||
|
compressed?: boolean;
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
export interface SupervisorResponse<T> {
|
||||||
|
result: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreInfoBody {
|
||||||
|
version: string;
|
||||||
|
version_latest: string;
|
||||||
|
update_available: boolean;
|
||||||
|
arch: string;
|
||||||
|
machine: string;
|
||||||
|
ip_address: string;
|
||||||
|
image: string;
|
||||||
|
boot: boolean;
|
||||||
|
port: number;
|
||||||
|
ssl: boolean;
|
||||||
|
watchdog: boolean;
|
||||||
|
wait_boot: number;
|
||||||
|
audio_input: string;
|
||||||
|
audio_output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddonData {
|
||||||
|
addons: AddonModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddonModel {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
advanced: boolean;
|
||||||
|
description: string;
|
||||||
|
repository: string;
|
||||||
|
version: string;
|
||||||
|
version_latest: string;
|
||||||
|
update_available: boolean;
|
||||||
|
installed: string;
|
||||||
|
available: boolean;
|
||||||
|
icon: boolean;
|
||||||
|
logo: boolean;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupData {
|
||||||
|
backups: BackupModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupModel {
|
||||||
|
slug: string;
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
type: "full" | "partial";
|
||||||
|
protected: boolean;
|
||||||
|
content: BackupContent;
|
||||||
|
compressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupContent {
|
||||||
|
homeassistant: boolean;
|
||||||
|
addons: string[];
|
||||||
|
folders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupDetailModel {
|
||||||
|
slug: string;
|
||||||
|
type: "full" | "partial";
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
size: string;
|
||||||
|
protected: boolean;
|
||||||
|
homeassistant: string;
|
||||||
|
addons: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
size: number;
|
||||||
|
}[];
|
||||||
|
repositories: string[];
|
||||||
|
folders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupUpload {
|
||||||
|
slug: string;
|
||||||
|
job_id: string;
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
export enum WorkflowType {
|
||||||
|
AUTO,
|
||||||
|
MANUAL,
|
||||||
|
}
|
15
nextcloud_backup/backend/src/types/services/webdav.ts
Normal file
15
nextcloud_backup/backend/src/types/services/webdav.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { DateTime } from "luxon";
|
||||||
|
|
||||||
|
export interface WebdavBackup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
lastEdit: DateTime;
|
||||||
|
path: string;
|
||||||
|
haVersion?: string;
|
||||||
|
creationDate?: DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebdavGenericPath {
|
||||||
|
path: string;
|
||||||
|
}
|
18
nextcloud_backup/backend/src/types/services/webdavConfig.ts
Normal file
18
nextcloud_backup/backend/src/types/services/webdavConfig.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export enum WebdavEndpointType {
|
||||||
|
NEXTCLOUD = "NEXTCLOUD",
|
||||||
|
CUSTOM = "CUSTOM",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebdavConfig {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
backupDir: string;
|
||||||
|
allowSelfSignedCerts: boolean;
|
||||||
|
chunckedUpload: boolean;
|
||||||
|
webdavEndpoint: {
|
||||||
|
type: WebdavEndpointType;
|
||||||
|
customEndpoint?: string;
|
||||||
|
customChunkEndpoint?: string;
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
import Joi from "joi";
|
||||||
|
import { WebdavEndpointType } from "./webdavConfig.js";
|
||||||
|
|
||||||
|
const WebdavConfigValidation = {
|
||||||
|
url: Joi.string().not().empty().uri().required().label("Url"),
|
||||||
|
username: Joi.string().not().empty().label("Username"),
|
||||||
|
password: Joi.string().not().empty().label("Password"),
|
||||||
|
backupDir: Joi.string().required().label("Backup directory"),
|
||||||
|
allowSelfSignedCerts: Joi.boolean().label("Allow self signed certificate"),
|
||||||
|
chunckedUpload: Joi.boolean().required().label("Chuncked upload"),
|
||||||
|
webdavEndpoint: Joi.object({
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(WebdavEndpointType.CUSTOM, WebdavEndpointType.NEXTCLOUD)
|
||||||
|
.required(),
|
||||||
|
customEndpoint: Joi.alternatives().conditional("type", {
|
||||||
|
is: WebdavEndpointType.CUSTOM,
|
||||||
|
then: Joi.string().not().empty().required(),
|
||||||
|
otherwise: Joi.disallow(),
|
||||||
|
}),
|
||||||
|
customChunkEndpoint: Joi.alternatives().conditional("type", {
|
||||||
|
is: WebdavEndpointType.CUSTOM,
|
||||||
|
then: Joi.string().not().empty().required(),
|
||||||
|
otherwise: Joi.disallow(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.required()
|
||||||
|
.label("Webdav endpoint"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebdavConfigValidation;
|
@ -0,0 +1,5 @@
|
|||||||
|
import Joi from "joi";
|
||||||
|
|
||||||
|
export const WebdavDeleteValidation = {
|
||||||
|
path: Joi.string().not().empty().required()
|
||||||
|
}
|
26
nextcloud_backup/backend/src/types/settings.ts
Normal file
26
nextcloud_backup/backend/src/types/settings.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface Settings {
|
||||||
|
name_template?: string;
|
||||||
|
cron_base?: string;
|
||||||
|
cron_hour?: string;
|
||||||
|
cron_weekday?: number;
|
||||||
|
cron_month_day?: number;
|
||||||
|
cron_custom?: string;
|
||||||
|
auto_clean_local?: string;
|
||||||
|
auto_clean_local_keep?: string;
|
||||||
|
auto_clean_backup?: string;
|
||||||
|
auto_clean_backup_keep?: string;
|
||||||
|
auto_stop_addon?: string[];
|
||||||
|
password_protected?: string;
|
||||||
|
password_protect_value?: string;
|
||||||
|
exclude_addon: string[];
|
||||||
|
exclude_folder: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebdavSettings {
|
||||||
|
ssl: boolean;
|
||||||
|
host: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
back_dir: string;
|
||||||
|
self_signed: boolean;
|
||||||
|
}
|
34
nextcloud_backup/backend/src/types/status.ts
Normal file
34
nextcloud_backup/backend/src/types/status.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { DateTime } from "luxon";
|
||||||
|
|
||||||
|
export enum States {
|
||||||
|
IDLE = "IDLE",
|
||||||
|
BKUP_CREATION = "BKUP_CREATION",
|
||||||
|
BKUP_DOWNLOAD_HA = "BKUP_DOWNLOAD_HA",
|
||||||
|
BKUP_DOWNLOAD_CLOUD = "BKUP_DOWNLOAD_CLOUD",
|
||||||
|
BKUP_UPLOAD_HA = "BKUP_UPLOAD_HA",
|
||||||
|
BKUP_UPLOAD_CLOUD = "BKUP_UPLOAD_CLOUD",
|
||||||
|
STOP_ADDON = "STOP_ADDON",
|
||||||
|
START_ADDON = "START_ADDON",
|
||||||
|
CLEAN_CLOUD = "CLEAN_CLOUD",
|
||||||
|
CLEAN_HA = "CLEAN_HA",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
status: States;
|
||||||
|
progress?: number;
|
||||||
|
last_backup: {
|
||||||
|
success?: boolean;
|
||||||
|
last_success?: DateTime;
|
||||||
|
last_try?: DateTime;
|
||||||
|
};
|
||||||
|
next_backup?: DateTime;
|
||||||
|
webdav: {
|
||||||
|
logged_in: boolean;
|
||||||
|
folder_created: boolean;
|
||||||
|
last_check: DateTime;
|
||||||
|
};
|
||||||
|
hass: {
|
||||||
|
ok: boolean;
|
||||||
|
last_check: DateTime;
|
||||||
|
};
|
||||||
|
}
|
14
nextcloud_backup/backend/tsconfig.json
Normal file
14
nextcloud_backup/backend/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
@ -16,9 +16,7 @@
|
|||||||
"arch": [
|
"arch": [
|
||||||
"aarch64",
|
"aarch64",
|
||||||
"amd64",
|
"amd64",
|
||||||
"armhf",
|
"armv7"
|
||||||
"armv7",
|
|
||||||
"i386"
|
|
||||||
],
|
],
|
||||||
"boot": "auto",
|
"boot": "auto",
|
||||||
"hassio_api": true,
|
"hassio_api": true,
|
||||||
|
1
nextcloud_backup/frontend/.env.development
Normal file
1
nextcloud_backup/frontend/.env.development
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="http://localhost:3000/"
|
1
nextcloud_backup/frontend/.env.production
Normal file
1
nextcloud_backup/frontend/.env.production
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="./"
|
72
nextcloud_backup/frontend/.eslintrc-auto-import.json
Normal file
72
nextcloud_backup/frontend/.eslintrc-auto-import.json
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"globals": {
|
||||||
|
"Component": true,
|
||||||
|
"ComponentPublicInstance": true,
|
||||||
|
"ComputedRef": true,
|
||||||
|
"EffectScope": true,
|
||||||
|
"ExtractDefaultPropTypes": true,
|
||||||
|
"ExtractPropTypes": true,
|
||||||
|
"ExtractPublicPropTypes": true,
|
||||||
|
"InjectionKey": true,
|
||||||
|
"PropType": true,
|
||||||
|
"Ref": true,
|
||||||
|
"VNode": true,
|
||||||
|
"WritableComputedRef": true,
|
||||||
|
"computed": true,
|
||||||
|
"createApp": true,
|
||||||
|
"customRef": true,
|
||||||
|
"defineAsyncComponent": true,
|
||||||
|
"defineComponent": true,
|
||||||
|
"effectScope": true,
|
||||||
|
"getCurrentInstance": true,
|
||||||
|
"getCurrentScope": true,
|
||||||
|
"h": true,
|
||||||
|
"inject": true,
|
||||||
|
"isProxy": true,
|
||||||
|
"isReactive": true,
|
||||||
|
"isReadonly": true,
|
||||||
|
"isRef": true,
|
||||||
|
"markRaw": true,
|
||||||
|
"nextTick": true,
|
||||||
|
"onActivated": true,
|
||||||
|
"onBeforeMount": true,
|
||||||
|
"onBeforeRouteLeave": true,
|
||||||
|
"onBeforeRouteUpdate": true,
|
||||||
|
"onBeforeUnmount": true,
|
||||||
|
"onBeforeUpdate": true,
|
||||||
|
"onDeactivated": true,
|
||||||
|
"onErrorCaptured": true,
|
||||||
|
"onMounted": true,
|
||||||
|
"onRenderTracked": true,
|
||||||
|
"onRenderTriggered": true,
|
||||||
|
"onScopeDispose": true,
|
||||||
|
"onServerPrefetch": true,
|
||||||
|
"onUnmounted": true,
|
||||||
|
"onUpdated": true,
|
||||||
|
"provide": true,
|
||||||
|
"reactive": true,
|
||||||
|
"readonly": true,
|
||||||
|
"ref": true,
|
||||||
|
"resolveComponent": true,
|
||||||
|
"shallowReactive": true,
|
||||||
|
"shallowReadonly": true,
|
||||||
|
"shallowRef": true,
|
||||||
|
"toRaw": true,
|
||||||
|
"toRef": true,
|
||||||
|
"toRefs": true,
|
||||||
|
"toValue": true,
|
||||||
|
"triggerRef": true,
|
||||||
|
"unref": true,
|
||||||
|
"useAttrs": true,
|
||||||
|
"useCssModule": true,
|
||||||
|
"useCssVars": true,
|
||||||
|
"useLink": true,
|
||||||
|
"useRoute": true,
|
||||||
|
"useRouter": true,
|
||||||
|
"useSlots": true,
|
||||||
|
"watch": true,
|
||||||
|
"watchEffect": true,
|
||||||
|
"watchPostEffect": true,
|
||||||
|
"watchSyncEffect": true
|
||||||
|
}
|
||||||
|
}
|
15
nextcloud_backup/frontend/.eslintrc.cjs
Normal file
15
nextcloud_backup/frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
'extends': [
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-typescript',
|
||||||
|
'@vue/eslint-config-prettier'
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest'
|
||||||
|
}
|
||||||
|
}
|
28
nextcloud_backup/frontend/.gitignore
vendored
Normal file
28
nextcloud_backup/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
1
nextcloud_backup/frontend/.prettierrc.json
Normal file
1
nextcloud_backup/frontend/.prettierrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
3
nextcloud_backup/frontend/.vscode/extensions.json
vendored
Normal file
3
nextcloud_backup/frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
46
nextcloud_backup/frontend/README.md
Normal file
46
nextcloud_backup/frontend/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# frontend
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm lint
|
||||||
|
```
|
197
nextcloud_backup/frontend/auto-imports.d.ts
vendored
Normal file
197
nextcloud_backup/frontend/auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
|
const computed: typeof import('vue')['computed']
|
||||||
|
const createApp: typeof import('vue')['createApp']
|
||||||
|
const customRef: typeof import('vue')['customRef']
|
||||||
|
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||||
|
const defineComponent: typeof import('vue')['defineComponent']
|
||||||
|
const effectScope: typeof import('vue')['effectScope']
|
||||||
|
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||||
|
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||||
|
const h: typeof import('vue')['h']
|
||||||
|
const inject: typeof import('vue')['inject']
|
||||||
|
const isProxy: typeof import('vue')['isProxy']
|
||||||
|
const isReactive: typeof import('vue')['isReactive']
|
||||||
|
const isReadonly: typeof import('vue')['isReadonly']
|
||||||
|
const isRef: typeof import('vue')['isRef']
|
||||||
|
const markRaw: typeof import('vue')['markRaw']
|
||||||
|
const nextTick: typeof import('vue')['nextTick']
|
||||||
|
const onActivated: typeof import('vue')['onActivated']
|
||||||
|
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||||
|
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||||
|
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||||
|
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||||
|
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||||
|
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||||
|
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||||
|
const onMounted: typeof import('vue')['onMounted']
|
||||||
|
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||||
|
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||||
|
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||||
|
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||||
|
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||||
|
const onUpdated: typeof import('vue')['onUpdated']
|
||||||
|
const provide: typeof import('vue')['provide']
|
||||||
|
const reactive: typeof import('vue')['reactive']
|
||||||
|
const readonly: typeof import('vue')['readonly']
|
||||||
|
const ref: typeof import('vue')['ref']
|
||||||
|
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||||
|
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||||
|
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||||
|
const shallowRef: typeof import('vue')['shallowRef']
|
||||||
|
const toRaw: typeof import('vue')['toRaw']
|
||||||
|
const toRef: typeof import('vue')['toRef']
|
||||||
|
const toRefs: typeof import('vue')['toRefs']
|
||||||
|
const toValue: typeof import('vue')['toValue']
|
||||||
|
const triggerRef: typeof import('vue')['triggerRef']
|
||||||
|
const unref: typeof import('vue')['unref']
|
||||||
|
const useAttrs: typeof import('vue')['useAttrs']
|
||||||
|
const useCssModule: typeof import('vue')['useCssModule']
|
||||||
|
const useCssVars: typeof import('vue')['useCssVars']
|
||||||
|
const useLink: typeof import('vue-router')['useLink']
|
||||||
|
const useRoute: typeof import('vue-router')['useRoute']
|
||||||
|
const useRouter: typeof import('vue-router')['useRouter']
|
||||||
|
const useSlots: typeof import('vue')['useSlots']
|
||||||
|
const watch: typeof import('vue')['watch']
|
||||||
|
const watchEffect: typeof import('vue')['watchEffect']
|
||||||
|
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||||
|
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||||
|
import('vue')
|
||||||
|
}
|
||||||
|
// for vue template auto import
|
||||||
|
import { UnwrapRef } from 'vue'
|
||||||
|
declare module 'vue' {
|
||||||
|
interface GlobalComponents {}
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
|
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||||
|
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||||
|
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||||
|
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||||
|
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||||
|
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||||
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
|
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||||
|
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||||
|
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||||
|
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||||
|
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||||
|
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||||
|
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||||
|
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||||
|
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||||
|
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||||
|
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||||
|
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||||
|
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||||
|
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||||
|
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||||
|
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||||
|
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||||
|
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||||
|
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||||
|
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||||
|
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||||
|
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||||
|
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||||
|
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||||
|
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||||
|
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||||
|
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||||
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||||
|
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||||
|
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||||
|
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||||
|
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||||
|
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||||
|
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||||
|
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||||
|
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||||
|
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||||
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||||
|
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||||
|
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||||
|
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||||
|
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||||
|
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||||
|
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||||
|
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||||
|
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||||
|
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||||
|
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
interface GlobalComponents {}
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
|
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||||
|
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||||
|
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||||
|
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||||
|
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||||
|
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||||
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
|
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||||
|
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||||
|
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||||
|
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||||
|
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||||
|
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||||
|
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||||
|
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||||
|
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||||
|
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||||
|
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||||
|
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||||
|
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||||
|
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||||
|
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||||
|
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||||
|
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||||
|
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||||
|
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||||
|
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||||
|
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||||
|
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||||
|
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||||
|
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||||
|
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||||
|
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||||
|
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||||
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||||
|
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||||
|
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||||
|
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||||
|
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||||
|
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||||
|
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||||
|
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||||
|
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||||
|
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||||
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||||
|
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||||
|
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||||
|
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||||
|
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||||
|
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||||
|
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||||
|
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||||
|
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||||
|
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||||
|
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||||
|
}
|
||||||
|
}
|
37
nextcloud_backup/frontend/components.d.ts
vendored
Normal file
37
nextcloud_backup/frontend/components.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
ActionComponent: typeof import('./src/components/statusBar/ActionComponent.vue')['default']
|
||||||
|
AlertManager: typeof import('./src/components/AlertManager.vue')['default']
|
||||||
|
BackupConfigAddon: typeof import('./src/components/settings/BackupConfig/BackupConfigAddon.vue')['default']
|
||||||
|
BackupConfigAutoBackup: typeof import('./src/components/settings/BackupConfig/BackupConfigAutoBackup.vue')['default']
|
||||||
|
BackupConfigAutoClean: typeof import('./src/components/settings/BackupConfig/BackupConfigAutoClean.vue')['default']
|
||||||
|
BackupConfigAutoStop: typeof import('./src/components/settings/BackupConfig/BackupConfigAutoStop.vue')['default']
|
||||||
|
BackupConfigFolder: typeof import('./src/components/settings/BackupConfig/BackupConfigFolder.vue')['default']
|
||||||
|
BackupConfigForm: typeof import('./src/components/settings/BackupConfigForm.vue')['default']
|
||||||
|
BackupConfigMenu: typeof import('./src/components/settings/BackupConfigMenu.vue')['default']
|
||||||
|
BackupConfigSecurity: typeof import('./src/components/settings/BackupConfig/BackupConfigSecurity.vue')['default']
|
||||||
|
BackupStatus: typeof import('./src/components/statusBar/BackupStatus.vue')['default']
|
||||||
|
CloudDeleteDialog: typeof import('./src/components/cloud/CloudDeleteDialog.vue')['default']
|
||||||
|
CloudList: typeof import('./src/components/cloud/CloudList.vue')['default']
|
||||||
|
CloudListItem: typeof import('./src/components/cloud/CloudListItem.vue')['default']
|
||||||
|
ConnectionStatus: typeof import('./src/components/statusBar/ConnectionStatus.vue')['default']
|
||||||
|
HaDeleteDialog: typeof import('./src/components/homeAssistant/HaDeleteDialog.vue')['default']
|
||||||
|
HaList: typeof import('./src/components/homeAssistant/HaList.vue')['default']
|
||||||
|
HaListItem: typeof import('./src/components/homeAssistant/HaListItem.vue')['default']
|
||||||
|
HaListItemContent: typeof import('./src/components/homeAssistant/HaListItemContent.vue')['default']
|
||||||
|
MessageBar: typeof import('./src/components/MessageBar.vue')['default']
|
||||||
|
NavbarComponent: typeof import('./src/components/NavbarComponent.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
StatusBar: typeof import('./src/components/statusBar/StatusBar.vue')['default']
|
||||||
|
WebdavConfigForm: typeof import('./src/components/settings/WebdavConfigForm.vue')['default']
|
||||||
|
WebdavConfigMenu: typeof import('./src/components/settings/WebdavConfigMenu.vue')['default']
|
||||||
|
}
|
||||||
|
}
|
16
nextcloud_backup/frontend/index.html
Normal file
16
nextcloud_backup/frontend/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nextcloud Backup</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
52
nextcloud_backup/frontend/package.json
Normal file
52
nextcloud_backup/frontend/package.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env NODE_OPTIONS='--no-warnings' vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --fix --ignore-path .gitignore",
|
||||||
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"build-to-back": "vue-tsc --noEmit && vite build --outDir ../backend/dist/public --emptyOutDir true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/font": "7.4.47",
|
||||||
|
"core-js": "^3.34.0",
|
||||||
|
"ky": "^1.2.0",
|
||||||
|
"luxon": "^3.4.4",
|
||||||
|
"pretty-bytes": "^6.1.1",
|
||||||
|
"roboto-fontface": "*",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"vue": "^3.3.0",
|
||||||
|
"vuetify": "3.5.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/types": "^7.23.0",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-standard": "^17.1.0",
|
||||||
|
"eslint-plugin-import": "^2.29.0",
|
||||||
|
"eslint-plugin-n": "^16.4.0",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-promise": "^6.1.1",
|
||||||
|
"eslint-plugin-vue": "^9.19.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"sass": "^1.69.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"unplugin-auto-import": "^0.17.3",
|
||||||
|
"unplugin-fonts": "^1.1.0",
|
||||||
|
"unplugin-vue-components": "^0.26.0",
|
||||||
|
"unplugin-vue-router": "^0.7.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-vue-layouts": "^0.10.0",
|
||||||
|
"vite-plugin-vuetify": "^2.0.0",
|
||||||
|
"vue-router": "^4.2.0",
|
||||||
|
"vue-tsc": "^1.8.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.15.3"
|
||||||
|
}
|
3121
nextcloud_backup/frontend/pnpm-lock.yaml
Normal file
3121
nextcloud_backup/frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
nextcloud_backup/frontend/public/favicon.ico
Normal file
BIN
nextcloud_backup/frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
41
nextcloud_backup/frontend/src/App.vue
Normal file
41
nextcloud_backup/frontend/src/App.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<navbar-component></navbar-component>
|
||||||
|
<message-bar></message-bar>
|
||||||
|
<webdav-config-menu @saved="cloudList?.refreshBackup"></webdav-config-menu>
|
||||||
|
<backup-config-menu></backup-config-menu>
|
||||||
|
<alert-manager></alert-manager>
|
||||||
|
<v-main class="mx-xl-16 mx-lg-10 mx-2">
|
||||||
|
<StatusBar @state-updated="refreshLists"></StatusBar>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" lg="6">
|
||||||
|
<ha-list ref="haList"></ha-list>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" lg="6">
|
||||||
|
<cloud-list ref="cloudList"></cloud-list>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import AlertManager from "./components/AlertManager.vue";
|
||||||
|
import CloudList from "./components/cloud/CloudList.vue";
|
||||||
|
import HaList from "./components/homeAssistant/HaList.vue";
|
||||||
|
import MessageBar from "./components/MessageBar.vue";
|
||||||
|
import NavbarComponent from "./components/NavbarComponent.vue";
|
||||||
|
import BackupConfigMenu from "./components/settings/BackupConfigMenu.vue";
|
||||||
|
import WebdavConfigMenu from "./components/settings/WebdavConfigMenu.vue";
|
||||||
|
import StatusBar from "./components/statusBar/StatusBar.vue";
|
||||||
|
const cloudList = ref<InstanceType<typeof CloudList> | null>(null);
|
||||||
|
const haList = ref<InstanceType<typeof HaList> | null>(null);
|
||||||
|
|
||||||
|
function refreshLists() {
|
||||||
|
cloudList.value?.refreshBackup();
|
||||||
|
haList.value?.refreshBackup();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
74
nextcloud_backup/frontend/src/assets/base.css
Normal file
74
nextcloud_backup/frontend/src/assets/base.css
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition: color 0.5s, background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
35
nextcloud_backup/frontend/src/assets/main.css
Normal file
35
nextcloud_backup/frontend/src/assets/main.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
@import "./base.css";
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
65
nextcloud_backup/frontend/src/components/AlertManager.vue
Normal file
65
nextcloud_backup/frontend/src/components/AlertManager.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<v-fade-transition>
|
||||||
|
<div id="parent" v-if="alertVisible">
|
||||||
|
<div id="alertContainer">
|
||||||
|
<v-slide-x-transition group tag="div">
|
||||||
|
<v-alert
|
||||||
|
v-for="alert of alertList"
|
||||||
|
v-bind:key="alert.id"
|
||||||
|
elevation="24"
|
||||||
|
:type="alert.type"
|
||||||
|
border="start"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-html="alert.message"></v-col>
|
||||||
|
<v-col cols="2">
|
||||||
|
<v-btn
|
||||||
|
class="d-inline"
|
||||||
|
size="30"
|
||||||
|
variant="text"
|
||||||
|
rounded
|
||||||
|
icon="$close"
|
||||||
|
@click="alertStore.remove(alert.id)"
|
||||||
|
></v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
:max="alertStore.timeOutValue"
|
||||||
|
:model-value="alert.timeOut"
|
||||||
|
></v-progress-linear>
|
||||||
|
</v-alert>
|
||||||
|
</v-slide-x-transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-fade-transition>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
|
||||||
|
const { alertList } = storeToRefs(alertStore);
|
||||||
|
|
||||||
|
const alertVisible = computed(() => alertList.value.length > 0);
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
#parent {
|
||||||
|
position: absolute;
|
||||||
|
top: 70px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 99999;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
#alertContainer {
|
||||||
|
position: sticky;
|
||||||
|
pointer-events: all;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@/store/alert
|
183
nextcloud_backup/frontend/src/components/MessageBar.vue
Normal file
183
nextcloud_backup/frontend/src/components/MessageBar.vue
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu activator="#message-btn" :close-on-content-click="false">
|
||||||
|
<v-sheet width="500" border rounded>
|
||||||
|
<v-toolbar color="surface" density="comfortable" border>
|
||||||
|
<v-toolbar-title>Messages</v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn icon @click="markAllReaded">
|
||||||
|
<v-icon color="success">mdi-check-all</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-responsive max-height="350px" class="overflow-y-auto">
|
||||||
|
<v-list class="py-0">
|
||||||
|
<v-data-iterator
|
||||||
|
:items="messages"
|
||||||
|
item-value="id"
|
||||||
|
items-per-page="-1"
|
||||||
|
>
|
||||||
|
<template v-slot:default="{ items, isExpanded, toggleExpand }">
|
||||||
|
<template v-for="(item, index) in items" :key="item.raw.id">
|
||||||
|
<v-divider v-if="index != 0"></v-divider>
|
||||||
|
<v-list-item :class="{ 'bg-brown-darken-4': !item.raw.viewed }">
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ item.raw.message }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon
|
||||||
|
:icon="getMessageIcon(item.raw.type)"
|
||||||
|
:color="getMessageColor(item.raw.type)"
|
||||||
|
class="mr-3"
|
||||||
|
></v-icon>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
v-if="item.raw.detail"
|
||||||
|
variant="text"
|
||||||
|
icon
|
||||||
|
color="secondary"
|
||||||
|
@click="toggleExpand(item as any)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
{{
|
||||||
|
isExpanded(item as any)
|
||||||
|
? "mdi-chevron-up"
|
||||||
|
: "mdi-information"
|
||||||
|
}}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-scroll-x-transition>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
variant="text"
|
||||||
|
@click="markReaded(item.raw.id)"
|
||||||
|
icon
|
||||||
|
v-show="!item.raw.viewed"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-check</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-scroll-x-transition>
|
||||||
|
|
||||||
|
<div class="text-caption text-disabled ml-1">
|
||||||
|
{{ getTimeDelta(item.raw.time) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-expand-transition v-if="item.raw.detail">
|
||||||
|
<div v-if="isExpanded(item as any)">
|
||||||
|
<v-divider class="mx-3"></v-divider>
|
||||||
|
<v-card
|
||||||
|
class="mx-3 my-2"
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
{{ item.raw.detail }}
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</v-expand-transition>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</v-data-iterator>
|
||||||
|
</v-list>
|
||||||
|
</v-responsive>
|
||||||
|
</v-sheet>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import * as messageService from "@/services/messageService";
|
||||||
|
import { useMessageStore } from "@/store/message";
|
||||||
|
import { MessageType } from "@/types/messages";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { onBeforeUnmount, ref } from "vue";
|
||||||
|
|
||||||
|
const messagesStore = useMessageStore();
|
||||||
|
const { messages } = storeToRefs(messagesStore);
|
||||||
|
|
||||||
|
const interval = setInterval(refreshMessages, 2000);
|
||||||
|
|
||||||
|
function refreshMessages() {
|
||||||
|
messageService.getMessages().then((values) => {
|
||||||
|
messages.value = values;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageColor(messageType: MessageType) {
|
||||||
|
switch (messageType) {
|
||||||
|
case MessageType.ERROR:
|
||||||
|
return "red";
|
||||||
|
case MessageType.WARN:
|
||||||
|
return "yellow";
|
||||||
|
case MessageType.INFO:
|
||||||
|
return "primary";
|
||||||
|
case MessageType.SUCCESS:
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageIcon(messageType: MessageType) {
|
||||||
|
switch (messageType) {
|
||||||
|
case MessageType.ERROR:
|
||||||
|
return "mdi-alert-octagram";
|
||||||
|
case MessageType.WARN:
|
||||||
|
return "mdi-alert";
|
||||||
|
case MessageType.INFO:
|
||||||
|
return "mdi-alert-circle";
|
||||||
|
case MessageType.SUCCESS:
|
||||||
|
return "mdi-check-circle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeDelta(time: string) {
|
||||||
|
let duration = DateTime.now().diff(DateTime.fromISO(time), ["seconds"]);
|
||||||
|
if (duration.seconds < 60) {
|
||||||
|
return duration.toHuman({
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
unitDisplay: "short",
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
duration = duration.shiftTo("minutes");
|
||||||
|
if (duration.minutes < 60) {
|
||||||
|
return duration.toHuman({
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
unitDisplay: "short",
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = duration.shiftTo("hours");
|
||||||
|
if (duration.hours < 24) {
|
||||||
|
return duration.toHuman({
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
unitDisplay: "short",
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
return duration.shiftTo("days").toHuman({
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
unitDisplay: "short",
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const show = ref<boolean[]>([]);
|
||||||
|
refreshMessages();
|
||||||
|
|
||||||
|
function markReaded(id: string) {
|
||||||
|
messageService.markRead(id).then((values) => {
|
||||||
|
messages.value = values;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllReaded() {
|
||||||
|
messageService.markAllRead().then((value) => {
|
||||||
|
messages.value = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
</script>
|
89
nextcloud_backup/frontend/src/components/NavbarComponent.vue
Normal file
89
nextcloud_backup/frontend/src/components/NavbarComponent.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<v-app-bar class="bg-primary">
|
||||||
|
<v-app-bar-title>
|
||||||
|
<div class="d-flex flex-row align-center">
|
||||||
|
<div>
|
||||||
|
<v-img :src="logoUrl" width="80"></v-img>
|
||||||
|
</div>
|
||||||
|
<h4>Nextcloud Backup</h4>
|
||||||
|
</div>
|
||||||
|
</v-app-bar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn icon id="message-btn">
|
||||||
|
<v-badge
|
||||||
|
color="error"
|
||||||
|
:content="countUnreadMessages"
|
||||||
|
v-if="haveUnreadMessages"
|
||||||
|
>
|
||||||
|
<v-icon class="shake">mdi-bell</v-icon>
|
||||||
|
</v-badge>
|
||||||
|
<v-icon v-else>mdi-bell</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-menu width="210px">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn icon v-bind="props">
|
||||||
|
<v-icon>mdi-cog</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
title="Cloud settings"
|
||||||
|
@click="dialogStatusStore.webdav = true"
|
||||||
|
prepend-icon="mdi-cloud"
|
||||||
|
></v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
title="Backup settings"
|
||||||
|
prepend-icon="mdi-rotate-left"
|
||||||
|
@click="dialogStatusStore.backup = true"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-app-bar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDialogStatusStore } from "@/store/dialogStatus";
|
||||||
|
import { useMessageStore } from "@/store/message";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import logoUrl from "../assets/logo.svg";
|
||||||
|
|
||||||
|
const dialogStatusStore = useDialogStatusStore();
|
||||||
|
const messagesStore = useMessageStore();
|
||||||
|
const { haveUnreadMessages, countUnreadMessages } = storeToRefs(messagesStore);
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.shake {
|
||||||
|
animation: shake 1.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) both infinite;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
20% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(0eg);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@/store/dialogStatus@/store/message
|
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
:width="width"
|
||||||
|
:fullscreen="isFullScreen"
|
||||||
|
:persistent="loading"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon color="red" class="mr-2">mdi-trash-can</v-icon> Delete Cloud
|
||||||
|
backup
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
Delete <v-code tag="code">{{ item?.name }}</v-code> backup in cloud ?
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn color="success" @click="dialog = false" :disabled="loading">
|
||||||
|
Close
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="red" @click="confirm()" :loading="loading">
|
||||||
|
<v-icon>mdi-trash-can</v-icon> Delete
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMenuSize } from "@/composable/menuSize";
|
||||||
|
import { deleteWebdabBackup } from "@/services/webdavService";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
import type { WebdavBackup } from "@/types/webdav";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const dialog = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const item = ref<WebdavBackup | null>(null);
|
||||||
|
|
||||||
|
const { width, isFullScreen } = useMenuSize();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "deleted"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
function confirm() {
|
||||||
|
loading.value = true;
|
||||||
|
if (item.value) {
|
||||||
|
deleteWebdabBackup(item.value?.path)
|
||||||
|
.then(() => {
|
||||||
|
loading.value = false;
|
||||||
|
dialog.value = false;
|
||||||
|
alertStore.add("success", "Backup deleted from cloud");
|
||||||
|
emit("deleted");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
alertStore.add("error", "Fail to deleted backup from cloud");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(value: WebdavBackup) {
|
||||||
|
item.value = value;
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
139
nextcloud_backup/frontend/src/components/cloud/CloudList.vue
Normal file
139
nextcloud_backup/frontend/src/components/cloud/CloudList.vue
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card elevation="10" border>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col offset="2">
|
||||||
|
<v-card-title class="text-center"> Cloud </v-card-title>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="2">
|
||||||
|
<v-btn
|
||||||
|
class="float-right mr-2"
|
||||||
|
icon="mdi-refresh"
|
||||||
|
variant="text"
|
||||||
|
@click="refreshBackup"
|
||||||
|
:loading="loading"
|
||||||
|
></v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-card variant="elevated" elevation="7" height="100%">
|
||||||
|
<v-card-title
|
||||||
|
class="text-center text-white bg-light-blue-darken-4"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider color="grey-darken-3"></v-divider>
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-list class="pa-0">
|
||||||
|
<v-list-item
|
||||||
|
v-if="autoBackups.length == 0"
|
||||||
|
class="text-center text-subtitle-2 text-disabled"
|
||||||
|
>Folder is empty</v-list-item
|
||||||
|
>
|
||||||
|
<cloud-list-item
|
||||||
|
v-for="(item, index) in autoBackups"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:index="index"
|
||||||
|
@delete="deleteBackup"
|
||||||
|
@upload="restore"
|
||||||
|
>
|
||||||
|
</cloud-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-card variant="elevated" elevation="7" height="100%">
|
||||||
|
<v-card-title
|
||||||
|
class="text-center text-white bg-light-blue-darken-4"
|
||||||
|
>Manual</v-card-title
|
||||||
|
>
|
||||||
|
<v-divider color="grey-darken-3"></v-divider>
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-list class="pa-0">
|
||||||
|
<v-list-item
|
||||||
|
v-if="manualBackups.length == 0"
|
||||||
|
class="text-center text-subtitle-2 text-disabled"
|
||||||
|
>Folder is empty</v-list-item
|
||||||
|
>
|
||||||
|
<cloud-list-item
|
||||||
|
v-for="(item, index) in manualBackups"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:index="index"
|
||||||
|
@delete="deleteBackup"
|
||||||
|
@upload="restore"
|
||||||
|
>
|
||||||
|
</cloud-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<cloud-delete-dialog
|
||||||
|
ref="deleteDialog"
|
||||||
|
@deleted="refreshBackup"
|
||||||
|
></cloud-delete-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref } from "vue";
|
||||||
|
import type { WebdavBackup } from "@/types/webdav";
|
||||||
|
import {
|
||||||
|
getAutoBackupList,
|
||||||
|
getManualBackupList,
|
||||||
|
restoreWebdavBackup,
|
||||||
|
} from "@/services/webdavService";
|
||||||
|
import CloudDeleteDialog from "./CloudDeleteDialog.vue";
|
||||||
|
import CloudListItem from "./CloudListItem.vue";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
|
||||||
|
const deleteDialog = ref<InstanceType<typeof CloudDeleteDialog> | null>(null);
|
||||||
|
const deleteItem = ref<WebdavBackup | null>(null);
|
||||||
|
const autoBackups = ref<WebdavBackup[]>([]);
|
||||||
|
const manualBackups = ref<WebdavBackup[]>([]);
|
||||||
|
|
||||||
|
const loading = ref<boolean>(true);
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
function refreshBackup() {
|
||||||
|
loading.value = true;
|
||||||
|
getAutoBackupList()
|
||||||
|
.then((value) => {
|
||||||
|
autoBackups.value = value;
|
||||||
|
return getManualBackupList();
|
||||||
|
})
|
||||||
|
.then((value) => {
|
||||||
|
manualBackups.value = value;
|
||||||
|
loading.value = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBackup(item: WebdavBackup) {
|
||||||
|
deleteItem.value = item;
|
||||||
|
deleteDialog.value?.open(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(item: WebdavBackup) {
|
||||||
|
restoreWebdavBackup(item.path)
|
||||||
|
.then(() => {
|
||||||
|
alertStore.add("success", "Backup upload as started.");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alertStore.add("error", "Fail to start backup upload !");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refreshBackup();
|
||||||
|
defineExpose({ refreshBackup });
|
||||||
|
</script>
|
132
nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue
Normal file
132
nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<v-divider v-if="index != 0" color="grey-darken-3"></v-divider>
|
||||||
|
<v-list-item class="bg-grey-darken-3">
|
||||||
|
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-scroll-x-transition>
|
||||||
|
<v-chip
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
class="mr-1"
|
||||||
|
v-show="!detail"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
DateTime.fromISO(item.lastEdit).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</v-scroll-x-transition>
|
||||||
|
|
||||||
|
<v-btn variant="text" icon color="success" @click="detail = !detail">
|
||||||
|
<v-icon>{{ detail ? "mdi-chevron-up" : "mdi-information" }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-card v-show="detail" variant="tonal" color="secondary" rounded="0">
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-tooltip text="Creation" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
class="mr-2"
|
||||||
|
label
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start icon="mdi-folder-plus"></v-icon>
|
||||||
|
{{
|
||||||
|
item.creationDate
|
||||||
|
? DateTime.fromISO(item.creationDate).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
: "UNKNOWN"
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-tooltip text="Home Assistant Version" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip color="success" variant="flat" label v-bind="props">
|
||||||
|
<v-icon start icon="mdi-home-assistant"></v-icon>
|
||||||
|
{{ item.haVersion ? item.haVersion : "UNKNOWN" }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-tooltip text="Last edit" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
class="mr-2"
|
||||||
|
label
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start icon="mdi-pencil"></v-icon>
|
||||||
|
{{
|
||||||
|
DateTime.fromISO(item.lastEdit).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-tooltip text="Size" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip color="success" variant="flat" label v-bind="props">
|
||||||
|
<v-icon start icon="mdi-database"></v-icon>
|
||||||
|
{{ prettyBytes(item.size) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider class="mx-4 border-opacity-25"></v-divider>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<v-tooltip text="Upload to Home Assitant" location="bottom">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="success"
|
||||||
|
v-bind="props"
|
||||||
|
@click="emits('upload', item)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-upload</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-btn variant="outlined" color="red" @click="emits('delete', item)">
|
||||||
|
<v-icon>mdi-trash-can</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-expand-transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { restoreWebdavBackup } from "@/services/webdavService";
|
||||||
|
import type { WebdavBackup } from "@/types/webdav";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const detail = ref(false);
|
||||||
|
const props = defineProps<{
|
||||||
|
item: WebdavBackup;
|
||||||
|
index: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: "delete", item: WebdavBackup): void;
|
||||||
|
(e: "upload", item: WebdavBackup): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
:width="width"
|
||||||
|
:fullscreen="isFullScreen"
|
||||||
|
:persistent="loading"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon color="red" class="mr-2">mdi-trash-can</v-icon> Delete Home
|
||||||
|
Assistant backup
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
Delete <v-code tag="code">{{ item?.name }}</v-code> backup in Home
|
||||||
|
Assistant ?
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn color="success" @click="dialog = false" :disabled="loading">
|
||||||
|
Close
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="red" @click="confirm()" :loading="loading">
|
||||||
|
<v-icon>mdi-trash-can</v-icon> Delete
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMenuSize } from "@/composable/menuSize";
|
||||||
|
import { deleteHomeAssistantBackup } from "@/services/homeAssistantService";
|
||||||
|
import { deleteWebdabBackup } from "@/services/webdavService";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
import { BackupModel } from "@/types/homeAssistant";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const dialog = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const item = ref<BackupModel | null>(null);
|
||||||
|
|
||||||
|
const { width, isFullScreen } = useMenuSize();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "deleted"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
function confirm() {
|
||||||
|
loading.value = true;
|
||||||
|
if (item.value) {
|
||||||
|
deleteHomeAssistantBackup(item.value.slug)
|
||||||
|
.then(() => {
|
||||||
|
loading.value = false;
|
||||||
|
dialog.value = false;
|
||||||
|
alertStore.add("success", "Backup deleted from Home Assistant");
|
||||||
|
emit("deleted");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
alertStore.add("error", "Fail to deleted backup from Home Assistant");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(value: BackupModel) {
|
||||||
|
item.value = value;
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card elevation="10" border>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col offset="2">
|
||||||
|
<v-card-title class="text-center"> Home Assistant </v-card-title>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="2">
|
||||||
|
<v-btn
|
||||||
|
class="float-right mr-2"
|
||||||
|
icon="mdi-refresh"
|
||||||
|
variant="text"
|
||||||
|
@click="refreshBackup"
|
||||||
|
:loading="loading"
|
||||||
|
></v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title
|
||||||
|
class="text-center text-white bg-light-blue-darken-4"
|
||||||
|
>Backups</v-card-title
|
||||||
|
>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-list class="pa-0">
|
||||||
|
<v-list-item
|
||||||
|
v-if="backups.length == 0"
|
||||||
|
class="text-center text-subtitle-2 text-disabled"
|
||||||
|
>No backup in Home Assistant</v-list-item
|
||||||
|
>
|
||||||
|
<ha-list-item
|
||||||
|
v-for="(item, index) in backups"
|
||||||
|
:key="item.slug"
|
||||||
|
:item="item"
|
||||||
|
:index="index"
|
||||||
|
@upload="upload"
|
||||||
|
@delete="deleteBackup"
|
||||||
|
>
|
||||||
|
</ha-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
<ha-delete-dialog
|
||||||
|
ref="deleteDialog"
|
||||||
|
@deleted="refreshBackup"
|
||||||
|
></ha-delete-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { BackupModel } from "@/types/homeAssistant";
|
||||||
|
import { ref, onBeforeUnmount } from "vue";
|
||||||
|
import {
|
||||||
|
getBackups,
|
||||||
|
uploadHomeAssistantBackup,
|
||||||
|
} from "@/services/homeAssistantService";
|
||||||
|
import HaListItem from "./HaListItem.vue";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
import HaDeleteDialog from "./HaDeleteDialog.vue";
|
||||||
|
|
||||||
|
const deleteDialog = ref<InstanceType<typeof HaDeleteDialog> | null>(null);
|
||||||
|
const backups = ref<BackupModel[]>([]);
|
||||||
|
|
||||||
|
const deleteItem = ref<BackupModel | null>(null);
|
||||||
|
|
||||||
|
const loading = ref<boolean>(true);
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
|
||||||
|
function refreshBackup() {
|
||||||
|
loading.value = true;
|
||||||
|
getBackups()
|
||||||
|
.then((value) => {
|
||||||
|
backups.value = value;
|
||||||
|
loading.value = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upload(item: BackupModel) {
|
||||||
|
uploadHomeAssistantBackup(item.slug)
|
||||||
|
.then(() => {
|
||||||
|
alertStore.add("success", "Backup upload as started.");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alertStore.add("error", "Fail to start backup upload !");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBackup(item: BackupModel) {
|
||||||
|
deleteItem.value = item;
|
||||||
|
deleteDialog.value?.open(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBackup();
|
||||||
|
|
||||||
|
defineExpose({ refreshBackup });
|
||||||
|
</script>
|
@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<v-divider v-if="index != 0" color="grey-darken-3"></v-divider>
|
||||||
|
<v-list-item class="bg-grey-darken-3">
|
||||||
|
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-scroll-x-transition>
|
||||||
|
<div v-if="!detail">
|
||||||
|
<v-chip color="primary" variant="flat" size="small" class="mr-2">
|
||||||
|
{{ item.type == "partial" ? "P" : "F" }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip color="primary" variant="flat" size="small" class="mr-1">
|
||||||
|
{{
|
||||||
|
DateTime.fromISO(item.date).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-scroll-x-transition>
|
||||||
|
<v-btn variant="text" icon color="success" @click="detail = !detail">
|
||||||
|
<v-icon>{{ detail ? "mdi-chevron-up" : "mdi-information" }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-card v-show="detail" variant="tonal" color="secondary" rounded="0">
|
||||||
|
<v-card-text>
|
||||||
|
<v-row v-if="!detailData">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular indeterminate></v-progress-circular>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<template v-if="detailData">
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-tooltip text="Creation" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
class="mr-2"
|
||||||
|
label
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start icon="mdi-folder-plus"></v-icon>
|
||||||
|
{{
|
||||||
|
DateTime.fromISO(detailData.date).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-tooltip text="Home Assistant Version" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip color="success" variant="flat" label v-bind="props">
|
||||||
|
<v-icon start icon="mdi-home-assistant"></v-icon>
|
||||||
|
{{ detailData.homeassistant }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-tooltip text="Password protection" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
:color="detailData.protected ? 'success' : 'primary'"
|
||||||
|
variant="flat"
|
||||||
|
class="mr-2"
|
||||||
|
label
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
start
|
||||||
|
:icon="
|
||||||
|
detailData.protected ? 'mdi-lock' : 'mdi-lock-open'
|
||||||
|
"
|
||||||
|
></v-icon>
|
||||||
|
{{ detailData.protected ? "Protected" : "Unprotected" }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-tooltip text="Size" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
class="mr-2"
|
||||||
|
label
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start icon="mdi-database"></v-icon>
|
||||||
|
{{ detailData.size }} MB
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-tooltip text="Backup Type" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip color="success" variant="flat" label v-bind="props">
|
||||||
|
<v-icon
|
||||||
|
start
|
||||||
|
:icon="
|
||||||
|
detailData.type == 'full'
|
||||||
|
? 'mdi-alpha-f-box'
|
||||||
|
: 'mdi-alpha-p-box'
|
||||||
|
"
|
||||||
|
></v-icon>
|
||||||
|
{{ detailData.type }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center text-white">
|
||||||
|
<h3>Content</h3>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider class="mt-2 border-opacity-25"></v-divider>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" lg="6">
|
||||||
|
<div class="text-center text-white mt-2">
|
||||||
|
<h3>Folders</h3>
|
||||||
|
</div>
|
||||||
|
<v-list density="compact" variant="tonal">
|
||||||
|
<ha-list-item-content
|
||||||
|
v-for="item of detailData.folders.sort()"
|
||||||
|
:name="item"
|
||||||
|
v-bind:key="item"
|
||||||
|
></ha-list-item-content>
|
||||||
|
</v-list>
|
||||||
|
</v-col>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-center text-white mt-2">
|
||||||
|
<h3>Addons</h3>
|
||||||
|
</div>
|
||||||
|
<v-list density="compact" variant="tonal">
|
||||||
|
<ha-list-item-content
|
||||||
|
v-for="item of detailData.addons.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
)"
|
||||||
|
:name="item.name"
|
||||||
|
v-bind:key="item.slug"
|
||||||
|
:version="item.version"
|
||||||
|
></ha-list-item-content>
|
||||||
|
</v-list>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider class="mx-4 border-opacity-25"></v-divider>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<v-tooltip text="Upload to Cloud" location="bottom">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="success"
|
||||||
|
v-bind="props"
|
||||||
|
@click="emits('upload', item)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-cloud-upload</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-btn variant="outlined" color="red" @click="emits('delete', item)">
|
||||||
|
<v-icon>mdi-trash-can</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-expand-transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getBackupDetail } from "@/services/homeAssistantService";
|
||||||
|
import type { BackupDetailModel, BackupModel } from "@/types/homeAssistant";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import HaListItemContent from "./HaListItemContent.vue";
|
||||||
|
|
||||||
|
const detail = ref(false);
|
||||||
|
const detailData = ref<BackupDetailModel | null>(null);
|
||||||
|
const props = defineProps<{
|
||||||
|
item: BackupModel;
|
||||||
|
index: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
watch(detail, (value) => {
|
||||||
|
if (value) {
|
||||||
|
getBackupDetail(props.item.slug).then((response) => {
|
||||||
|
detailData.value = response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: "delete", item: BackupModel): void;
|
||||||
|
(e: "upload", item: BackupModel): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<v-list-item :title="name" rounded>
|
||||||
|
<template v-slot:append v-if="version">
|
||||||
|
<div class="text-secondary">{{ version }}</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7" height="100%">
|
||||||
|
<v-card-title class="text-center text-white bg-light-blue-darken-4">
|
||||||
|
Addons
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-white px-2 py-1">
|
||||||
|
<div v-if="loading" class="d-flex justify-center">
|
||||||
|
<v-progress-circular indeterminate color="orange"></v-progress-circular>
|
||||||
|
</div>
|
||||||
|
<v-checkbox
|
||||||
|
v-else
|
||||||
|
v-for="addon in addons"
|
||||||
|
v-model="invertedAddons"
|
||||||
|
:key="addon.slug"
|
||||||
|
:label="addon.name"
|
||||||
|
:value="addon.slug"
|
||||||
|
:loading="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
color="orange"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
</v-checkbox>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { watch } from "vue";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
const { data, addons, invertedAddons } = storeToRefs(backupConfigStore);
|
||||||
|
watch(invertedAddons, manageInverted);
|
||||||
|
|
||||||
|
manageInverted();
|
||||||
|
|
||||||
|
function manageInverted() {
|
||||||
|
if (!data.value.exclude) {
|
||||||
|
backupConfigStore.initExcludes();
|
||||||
|
}
|
||||||
|
data.value.exclude!.addon = [];
|
||||||
|
for (const addon of addons.value) {
|
||||||
|
if (!invertedAddons.value.includes(addon.slug)) {
|
||||||
|
data.value.exclude!.addon.push(addon.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7">
|
||||||
|
<v-card-title class="bg-light-blue-darken-4">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="10" offset="1" class="text-center text-white">
|
||||||
|
Auto Backup
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="1">
|
||||||
|
<v-btn
|
||||||
|
class="float-right"
|
||||||
|
size="32"
|
||||||
|
color="green"
|
||||||
|
rounded
|
||||||
|
@click="backupConfigStore.addEmptyCron()"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-plus</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pa-0 bg-blue-grey-darken-4">
|
||||||
|
<v-expansion-panels variant="inset" v-model="expansionPanelModel">
|
||||||
|
<v-expansion-panel v-for="cron in data.cron" :key="cron.id">
|
||||||
|
<v-expansion-panel-title>
|
||||||
|
<template v-slot:default="{ expanded }">
|
||||||
|
{{ CronModeFriendly[cron.mode] }}
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-chip
|
||||||
|
v-if="errors.includes(cron.id)"
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-alert"
|
||||||
|
>
|
||||||
|
INVALID
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-if="!expanded && cron.monthDay != undefined"
|
||||||
|
append-icon="mdi-calendar"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ cron.monthDay }}
|
||||||
|
</v-chip>
|
||||||
|
</v-fade-transition>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-chip
|
||||||
|
v-if="!expanded && cron.weekday != undefined"
|
||||||
|
append-icon="mdi-calendar"
|
||||||
|
size="small"
|
||||||
|
class="ml-3"
|
||||||
|
>
|
||||||
|
{{ weekdayFriendly[cron.weekday] }}
|
||||||
|
</v-chip>
|
||||||
|
</v-fade-transition>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-chip
|
||||||
|
v-if="!expanded && cron.hour"
|
||||||
|
append-icon="mdi-clock"
|
||||||
|
size="small"
|
||||||
|
class="mx-3"
|
||||||
|
>
|
||||||
|
{{ cron.hour }}
|
||||||
|
</v-chip>
|
||||||
|
</v-fade-transition>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-chip
|
||||||
|
v-if="!expanded && cron.custom"
|
||||||
|
append-icon="mdi-clock-edit"
|
||||||
|
size="small"
|
||||||
|
class="mx-3"
|
||||||
|
>{{ cron.custom }}</v-chip
|
||||||
|
>
|
||||||
|
</v-fade-transition>
|
||||||
|
</template>
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Mode <v-icon class="float-right">mdi-cog</v-icon>
|
||||||
|
</div>
|
||||||
|
<v-select
|
||||||
|
:items="
|
||||||
|
Object.entries(CronMode).map((value) => {
|
||||||
|
return { title: value[0], value: value[1] };
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-model="cron.mode"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="cron.mode == CronMode.Weekly" dense>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Day of week <v-icon class="float-right">mdi-calendar</v-icon>
|
||||||
|
</div>
|
||||||
|
<v-select
|
||||||
|
v-model="cron.weekday"
|
||||||
|
:items="
|
||||||
|
weekdayFriendly.map((value, index) => {
|
||||||
|
return { title: value, value: index };
|
||||||
|
})
|
||||||
|
"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="cron.mode == CronMode.Monthly" dense>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Day of month <v-icon class="float-right">mdi-calendar</v-icon>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="cron.monthDay"
|
||||||
|
type="number"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
min="1"
|
||||||
|
max="28"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="cron.mode != CronMode.Custom" dense>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Hour <v-icon class="float-right">mdi-clock</v-icon>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="cron.hour"
|
||||||
|
type="time"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="cron.mode == CronMode.Custom" dense>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Custom CRON
|
||||||
|
<v-icon class="float-right">mdi-clock-edit</v-icon>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="cron.custom"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
min="1"
|
||||||
|
max="28"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col align-self="center" class="text-center">
|
||||||
|
<v-btn color="red" @click="removeCron(cron.id)"
|
||||||
|
><v-icon>mdi-trash-can</v-icon></v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
<div
|
||||||
|
v-if="data.cron?.length == 0"
|
||||||
|
class="my-3 text-subtitle-2 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
No auto backup configured
|
||||||
|
</div>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import {
|
||||||
|
CronMode,
|
||||||
|
CronModeFriendly,
|
||||||
|
weekdayFriendly,
|
||||||
|
} from "@/types/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const expansionPanelModel = ref(undefined);
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean, errors: string[] }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
|
||||||
|
function removeCron(id: string) {
|
||||||
|
expansionPanelModel.value = undefined;
|
||||||
|
backupConfigStore.removeCron(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = storeToRefs(backupConfigStore);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7">
|
||||||
|
<v-card-title class="bg-light-blue-darken-4 text-center">
|
||||||
|
Auto Clean
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row class="mt-0" v-if="!loading">
|
||||||
|
<v-col class="" cols="12" md="6">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<v-switch
|
||||||
|
label="Auto clean Home Assistant backups"
|
||||||
|
v-model="data.autoClean.homeAssistant.enabled"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-row dense v-if="data.autoClean.homeAssistant.enabled">
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Number of backup to keep
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="data.autoClean.homeAssistant.nbrToKeep"
|
||||||
|
type="number"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
min="1"
|
||||||
|
:loading="loading"
|
||||||
|
:error-messages="errors.nbrToKeep"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-fade-transition>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<v-switch
|
||||||
|
label="Auto clean Cloud backups"
|
||||||
|
v-model="data.autoClean.webdav.enabled"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-row dense v-if="data.autoClean.webdav.enabled">
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Number of backup to keep
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="data.autoClean.webdav.nbrToKeep"
|
||||||
|
type="number"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
min="1"
|
||||||
|
:loading="loading"
|
||||||
|
:error-messages="errors.nbrToKeep"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-fade-transition>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean, errors: any }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
const { data } = storeToRefs(backupConfigStore);
|
||||||
|
</script>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7" height="100%">
|
||||||
|
<v-card-title class="text-center text-white bg-light-blue-darken-4">
|
||||||
|
Auto Stop Addon
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-white px-2 py-1">
|
||||||
|
<div v-if="loading" class="d-flex justify-center">
|
||||||
|
<v-progress-circular indeterminate color="orange"></v-progress-circular>
|
||||||
|
</div>
|
||||||
|
<v-checkbox
|
||||||
|
v-else
|
||||||
|
v-for="addon in addons"
|
||||||
|
v-model="data.autoStopAddon"
|
||||||
|
:key="addon.slug"
|
||||||
|
:label="addon.name"
|
||||||
|
:value="addon.slug"
|
||||||
|
:loading="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
color="orange"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
</v-checkbox>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
const { data, addons } = storeToRefs(backupConfigStore);
|
||||||
|
</script>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7" height="100%">
|
||||||
|
<v-card-title class="text-center text-white bg-light-blue-darken-4">
|
||||||
|
Folders
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-white px-2 py-1">
|
||||||
|
<div v-if="loading" class="d-flex justify-center">
|
||||||
|
<v-progress-circular indeterminate color="orange"></v-progress-circular>
|
||||||
|
</div>
|
||||||
|
<v-checkbox
|
||||||
|
v-else
|
||||||
|
v-for="folder in folders"
|
||||||
|
v-model="invertedFolders"
|
||||||
|
:key="folder.slug"
|
||||||
|
:label="folder.name"
|
||||||
|
:value="folder.slug"
|
||||||
|
:loading="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
color="orange"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
</v-checkbox>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { watch } from "vue";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
|
||||||
|
const { data, folders, invertedFolders } = storeToRefs(backupConfigStore);
|
||||||
|
watch(invertedFolders, manageInverted);
|
||||||
|
|
||||||
|
manageInverted();
|
||||||
|
|
||||||
|
function manageInverted() {
|
||||||
|
if (!data.value.exclude) {
|
||||||
|
backupConfigStore.initExcludes();
|
||||||
|
}
|
||||||
|
data.value.exclude!.folder = [];
|
||||||
|
for (const folder of folders.value) {
|
||||||
|
if (!invertedFolders.value.includes(folder.slug)) {
|
||||||
|
data.value.exclude!.folder.push(folder.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="elevated" elevation="7">
|
||||||
|
<v-card-title class="bg-light-blue-darken-4 text-center">
|
||||||
|
Security
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row justify="center" v-if="loading">
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="orange"
|
||||||
|
></v-progress-circular>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<template v-if="!loading">
|
||||||
|
<v-row class="mt-1">
|
||||||
|
<v-col>
|
||||||
|
<v-switch
|
||||||
|
label="Password protected backup"
|
||||||
|
v-model="data.password.enabled"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-row dense v-if="data.password.enabled">
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Password</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="data.password.value"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
type="password"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-fade-transition>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean }>();
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
const { data } = storeToRefs(backupConfigStore);
|
||||||
|
</script>
|
||||||
|
@/store/backupConfig
|
@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<v-form class="mx-4" @submit.prevent>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Naming template</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="{type}-{ha_version}-{date}_{hour}"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-tag"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.nameTemplate"
|
||||||
|
:error-messages="errors.nameTemplate"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
href="https://github.com/Sebclem/hassio-nextcloud-backup/blob/master/nextcloud_backup/naming_template.md"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-help-circle-outline"></v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Backup Type</div>
|
||||||
|
<v-select
|
||||||
|
:items="
|
||||||
|
Object.entries(BackupType).map((value) => {
|
||||||
|
return { title: value[0], value: value[1] };
|
||||||
|
})
|
||||||
|
"
|
||||||
|
v-model="data.backupType"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
color="orange"
|
||||||
|
:loading="loading"
|
||||||
|
:error-messages="errors.backupType"
|
||||||
|
>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-fade-transition>
|
||||||
|
<v-row v-if="data.backupType == BackupType.Partial">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<BackupConfigFolder :loading="loading"></BackupConfigFolder>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<BackupConfigAddon :loading="loading"></BackupConfigAddon>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-fade-transition>
|
||||||
|
<v-divider class="my-4 border-opacity-25"></v-divider>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-sheet border elevation="5" rounded class="py-1">
|
||||||
|
<h2>Automation</h2>
|
||||||
|
</v-sheet>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<BackupConfigAutoBackup :loading="loading" :errors="errors.cron"></BackupConfigAutoBackup>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<BackupConfigAutoClean :loading="loading" :errors=errors></BackupConfigAutoClean>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<BackupConfigAutoStop :loading="loading"></BackupConfigAutoStop>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider class="my-4 border-opacity-25"></v-divider>
|
||||||
|
<v-row class="mb-10">
|
||||||
|
<v-col>
|
||||||
|
<BackupConfigSecurity :loading="loading"></BackupConfigSecurity>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import { useConfigForm } from "@/composable/ConfigForm";
|
||||||
|
import { saveBackupConfig } from "@/services/configService";
|
||||||
|
import { useBackupConfigStore } from "@/store/backupConfig";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import BackupConfigAddon from "./BackupConfig/BackupConfigAddon.vue";
|
||||||
|
import BackupConfigAutoBackup from "./BackupConfig/BackupConfigAutoBackup.vue";
|
||||||
|
import BackupConfigFolder from "./BackupConfig/BackupConfigFolder.vue";
|
||||||
|
import BackupConfigAutoClean from "./BackupConfig/BackupConfigAutoClean.vue";
|
||||||
|
import BackupConfigSecurity from "./BackupConfig/BackupConfigSecurity.vue";
|
||||||
|
import BackupConfigAutoStop from "./BackupConfig/BackupConfigAutoStop.vue";
|
||||||
|
import { BackupType } from "@/types/backupConfig";
|
||||||
|
|
||||||
|
const backupConfigStore = useBackupConfigStore();
|
||||||
|
const { data } = storeToRefs(backupConfigStore);
|
||||||
|
const errors = ref({
|
||||||
|
nameTemplate: [],
|
||||||
|
backupType: [],
|
||||||
|
nbrToKeep: [],
|
||||||
|
backupDir: [],
|
||||||
|
allowSelfSignedCerts: [],
|
||||||
|
type: [],
|
||||||
|
customEndpoint: [],
|
||||||
|
cron: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "success"): void;
|
||||||
|
(e: "fail"): void;
|
||||||
|
(e: "loaded"): void;
|
||||||
|
(e: "loading"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { save, loading } = useConfigForm(
|
||||||
|
saveBackupConfig,
|
||||||
|
backupConfigStore.loadAll,
|
||||||
|
data,
|
||||||
|
errors,
|
||||||
|
emit
|
||||||
|
);
|
||||||
|
defineExpose({ save });
|
||||||
|
</script>
|
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogStatusStore.backup"
|
||||||
|
persistent
|
||||||
|
:width="width"
|
||||||
|
:fullscreen="isFullScreen"
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-center">Backup Settings</v-card-title>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<backup-config-form
|
||||||
|
ref="form"
|
||||||
|
@fail="fail"
|
||||||
|
@success="saved"
|
||||||
|
@loaded="loading = false"
|
||||||
|
@loading="loading = true"
|
||||||
|
></backup-config-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn
|
||||||
|
color="red"
|
||||||
|
@click="dialogStatusStore.backup = false"
|
||||||
|
:disabled="saving"
|
||||||
|
>Cancel</v-btn
|
||||||
|
>
|
||||||
|
<v-btn color="success" @click="save()" :loading="saveLoading"
|
||||||
|
>Save</v-btn
|
||||||
|
>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDialogStatusStore } from "@/store/dialogStatus";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { useMenuSize } from "@/composable/menuSize";
|
||||||
|
import BackupConfigForm from "./BackupConfigForm.vue";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
|
||||||
|
const dialogStatusStore = useDialogStatusStore();
|
||||||
|
const form = ref<InstanceType<typeof BackupConfigForm> | null>(null);
|
||||||
|
const { width, isFullScreen } = useMenuSize();
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
let saveLoading = computed(() => {
|
||||||
|
return saving.value || loading.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
saving.value = true;
|
||||||
|
form.value?.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail() {
|
||||||
|
saving.value = false;
|
||||||
|
alertStore.add("error", "Fail to save backup settings !");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saved() {
|
||||||
|
dialogStatusStore.backup = false;
|
||||||
|
saving.value = false;
|
||||||
|
alertStore.add("success", "Backup settings saved !");
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<v-form class="mx-4">
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">URL</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="https://exemple.com"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-web"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.url"
|
||||||
|
:error-messages="errors.url"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Endpoint</div>
|
||||||
|
<v-select
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
:items="items"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.webdavEndpoint.type"
|
||||||
|
:error-messages="errors.type"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<div v-if="data.webdavEndpoint.type == WebdavEndpointType.CUSTOM">
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Custom endpoint
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="/remote.php/dav/files/$username"
|
||||||
|
hint="You can use the $username variable"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.webdavEndpoint.customEndpoint"
|
||||||
|
:error-messages="errors.customEndpoint"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">
|
||||||
|
Custom chunk endpoint
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="/remote.php/dav/uploads/$username"
|
||||||
|
hint="You can use the $username variable"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.webdavEndpoint.customChunkEndpoint"
|
||||||
|
:error-messages="errors.customChunkEndpoint"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row class="mt-0">
|
||||||
|
<v-col class="d-flex align-content-end">
|
||||||
|
<v-switch
|
||||||
|
label="Allow self signed certificate"
|
||||||
|
v-model="data.allowSelfSignedCerts"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:error-messages="errors.allowSelfSignedCerts"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row class="mt-0">
|
||||||
|
<v-col class="d-flex align-content-end">
|
||||||
|
<v-switch
|
||||||
|
label="Chunked upload (Beta)"
|
||||||
|
v-model="data.chunckedUpload"
|
||||||
|
hide-details="auto"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:error-messages="errors.chunckedUpload"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row class="mt-0">
|
||||||
|
<v-col><v-divider></v-divider></v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-alert type="info" variant="outlined">
|
||||||
|
You need to use <b>App password</b>.<br />
|
||||||
|
More info
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://github.com/Sebclem/hassio-nextcloud-backup/blob/master/nextcloud_backup/DOCS.md#nextcloud-config"
|
||||||
|
>
|
||||||
|
here.
|
||||||
|
</a>
|
||||||
|
</v-alert>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Username</div>
|
||||||
|
<v-text-field
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
placeholder="Username"
|
||||||
|
prepend-inner-icon="mdi-account"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.username"
|
||||||
|
:error-messages="errors.username"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
</v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">App password</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="App password"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-key"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.password"
|
||||||
|
:error-messages="errors.password"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col><v-divider></v-divider></v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="text-subtitle-1 text-medium-emphasis">Backup folder</div>
|
||||||
|
<v-text-field
|
||||||
|
placeholder="Backup folder"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-folder"
|
||||||
|
hide-details="auto"
|
||||||
|
v-model="data.backupDir"
|
||||||
|
:error-messages="errors.backupDir"
|
||||||
|
:loading="loading"
|
||||||
|
color="orange"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { WebdavEndpointType, type WebdavConfig } from "@/types/webdavConfig";
|
||||||
|
import { getWebdavConfig, saveWebdavConfig } from "@/services/configService";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useConfigForm } from "@/composable/ConfigForm";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: "Nextcloud",
|
||||||
|
value: WebdavEndpointType.NEXTCLOUD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Custom",
|
||||||
|
value: WebdavEndpointType.CUSTOM,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const errors = ref({
|
||||||
|
url: [],
|
||||||
|
username: [],
|
||||||
|
password: [],
|
||||||
|
backupDir: [],
|
||||||
|
allowSelfSignedCerts: [],
|
||||||
|
type: [],
|
||||||
|
customEndpoint: [],
|
||||||
|
customChunkEndpoint: [],
|
||||||
|
chunckedUpload: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = ref<WebdavConfig>({
|
||||||
|
url: "",
|
||||||
|
allowSelfSignedCerts: false,
|
||||||
|
backupDir: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
chunckedUpload: false,
|
||||||
|
webdavEndpoint: {
|
||||||
|
type: WebdavEndpointType.NEXTCLOUD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "success"): void;
|
||||||
|
(e: "fail"): void;
|
||||||
|
(e: "loaded"): void;
|
||||||
|
(e: "loading"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
return getWebdavConfig().then((value) => {
|
||||||
|
data.value = value;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { save, loading } = useConfigForm(
|
||||||
|
saveWebdavConfig,
|
||||||
|
loadData,
|
||||||
|
data,
|
||||||
|
errors,
|
||||||
|
emit
|
||||||
|
);
|
||||||
|
defineExpose({ save });
|
||||||
|
</script>
|
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogStatusStore.webdav"
|
||||||
|
persistent
|
||||||
|
:width="width"
|
||||||
|
:fullscreen="isFullScreen"
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-center">Cloud Settings</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<webdav-settings-form
|
||||||
|
ref="form"
|
||||||
|
@fail="fail"
|
||||||
|
@success="saved"
|
||||||
|
@loaded="loading = false"
|
||||||
|
@loading="loading = true"
|
||||||
|
></webdav-settings-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn
|
||||||
|
color="red"
|
||||||
|
@click="dialogStatusStore.webdav = false"
|
||||||
|
:disabled="saving"
|
||||||
|
>Cancel</v-btn
|
||||||
|
>
|
||||||
|
<v-btn color="success" @click="save()" :loading="saveLoading"
|
||||||
|
>Save</v-btn
|
||||||
|
>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMenuSize } from "@/composable/menuSize";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
import { useDialogStatusStore } from "@/store/dialogStatus";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import WebdavSettingsForm from "./WebdavConfigForm.vue";
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
|
||||||
|
const dialogStatusStore = useDialogStatusStore();
|
||||||
|
const form = ref<InstanceType<typeof WebdavSettingsForm> | null>(null);
|
||||||
|
const { width, isFullScreen } = useMenuSize();
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
let saveLoading = computed(() => {
|
||||||
|
return saving.value || loading.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "saved"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
saving.value = true;
|
||||||
|
form.value?.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail() {
|
||||||
|
saving.value = false;
|
||||||
|
alertStore.add(
|
||||||
|
"error",
|
||||||
|
"Fail to connect to Cloud<br>Please check credentials !"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saved() {
|
||||||
|
dialogStatusStore.webdav = false;
|
||||||
|
saving.value = false;
|
||||||
|
alertStore.add("success", "Cloud settings saved !");
|
||||||
|
emit("saved");
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border>
|
||||||
|
<v-card-title class="text-center">Action</v-card-title>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="success"
|
||||||
|
@click="launchBackup"
|
||||||
|
prepend-icon="mdi-cloud-plus"
|
||||||
|
>
|
||||||
|
Backup Now
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="orange-darken-3"
|
||||||
|
@click="launchClean"
|
||||||
|
prepend-icon="mdi-broom"
|
||||||
|
>
|
||||||
|
Clean
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { backupNow, clean } from "@/services/actionService";
|
||||||
|
import { useAlertStore } from "@/store/alert";
|
||||||
|
|
||||||
|
const alertStore = useAlertStore();
|
||||||
|
|
||||||
|
function launchBackup() {
|
||||||
|
backupNow()
|
||||||
|
.then(() => {
|
||||||
|
alertStore.add("success", "Backup workflow started !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alertStore.add("error", "Fail to start backup workflow !");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchClean() {
|
||||||
|
clean()
|
||||||
|
.then(() => {
|
||||||
|
alertStore.add("success", "Backup workflow started !");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alertStore.add("error", "Fail to start backup workflow !");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border>
|
||||||
|
<v-card-item>
|
||||||
|
<v-card-title class="text-center">Backup</v-card-title>
|
||||||
|
</v-card-item>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col xl="6" lg="12" sm="6" cols="12">
|
||||||
|
<div class="h-100 d-flex align-center">
|
||||||
|
<span class="me-auto">Last</span>
|
||||||
|
<v-tooltip content-class="bg-black">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="props"
|
||||||
|
variant="elevated"
|
||||||
|
:prepend-icon="lastBackupProps.icon"
|
||||||
|
:color="lastBackupProps.color"
|
||||||
|
:text="lastBackupProps.text"
|
||||||
|
>
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<p>
|
||||||
|
Last try:
|
||||||
|
{{
|
||||||
|
status?.last_backup.last_try
|
||||||
|
? DateTime.fromISO(
|
||||||
|
status?.last_backup.last_try
|
||||||
|
).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: "Unknown"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Last success:
|
||||||
|
{{
|
||||||
|
status?.last_backup.last_success
|
||||||
|
? DateTime.fromISO(
|
||||||
|
status?.last_backup.last_success
|
||||||
|
).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: "Unknown"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-divider vertical class="border-opacity-25 mt-n1"></v-divider>
|
||||||
|
<v-col xl="6" lg="12" sm="6" cols="12">
|
||||||
|
<div class="h-100 d-flex align-center">
|
||||||
|
<span class="me-auto">Next</span>
|
||||||
|
<v-chip
|
||||||
|
variant="elevated"
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-update"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
status?.next_backup
|
||||||
|
? DateTime.fromISO(status?.next_backup).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
: "Unknown"
|
||||||
|
}}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="status?.status != States.IDLE">
|
||||||
|
<v-divider class="border-opacity-25 mx-n1"></v-divider>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="status?.status != States.IDLE">
|
||||||
|
<v-col>
|
||||||
|
<v-progress-linear
|
||||||
|
height="25"
|
||||||
|
:model-value="percent"
|
||||||
|
:indeterminate="indeterminate"
|
||||||
|
class=""
|
||||||
|
color="success"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<strong>{{ status?.status }}</strong>
|
||||||
|
</v-progress-linear>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { States, Status } from "@/types/status";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status?: Status;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const percent = computed(() => {
|
||||||
|
if (props.status?.status == States.IDLE || !props.status?.progress) {
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
return props.status.progress * 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastBackupProps = computed(() => {
|
||||||
|
if (props.status?.last_backup.success == undefined) {
|
||||||
|
return { icon: "mdi-help-circle", text: "Unknown", color: "" };
|
||||||
|
} else if (props.status?.last_backup.success) {
|
||||||
|
return { icon: "mdi-check", text: "Success", color: "green" };
|
||||||
|
} else {
|
||||||
|
return { icon: "mdi-alert", text: "Fail", color: "red" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const indeterminate = computed(()=> {
|
||||||
|
return props.status?.progress == -1 && props.status.status != States.IDLE
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border elevation="10">
|
||||||
|
<v-card-item>
|
||||||
|
<v-card-title class="text-center">Status</v-card-title>
|
||||||
|
</v-card-item>
|
||||||
|
<v-divider class="border-opacity-25"></v-divider>
|
||||||
|
<v-card-text class="h-auto">
|
||||||
|
<v-row align-content="space-around">
|
||||||
|
<v-col xl="6" lg="12" sm="6" cols="12">
|
||||||
|
<div class="h-100 d-flex align-center">
|
||||||
|
<span class="me-auto">Home Assistant</span>
|
||||||
|
<v-tooltip content-class="bg-black">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="props"
|
||||||
|
variant="elevated"
|
||||||
|
:prepend-icon="hassProps.icon"
|
||||||
|
:color="hassProps.color"
|
||||||
|
:text="hassProps.text"
|
||||||
|
>
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
Last check:
|
||||||
|
{{
|
||||||
|
status?.hass.last_check
|
||||||
|
? DateTime.fromISO(status.hass.last_check).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
: "UNKNOWN"
|
||||||
|
}}
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-divider vertical class="border-opacity-25 my-n1"></v-divider>
|
||||||
|
<v-col xl="6" lg="12" sm="6" cols="12">
|
||||||
|
<div class="h-100 d-flex align-center">
|
||||||
|
<span class="me-auto">Cloud</span>
|
||||||
|
<v-tooltip content-class="bg-black">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="props"
|
||||||
|
variant="elevated"
|
||||||
|
:prepend-icon="webdavProps.icon"
|
||||||
|
:color="webdavProps.color"
|
||||||
|
:text="webdavProps.text"
|
||||||
|
>
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<span>Login: </span>
|
||||||
|
<span :class="'text-' + webdavLoggedProps.color">
|
||||||
|
{{ webdavLoggedProps.text }}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span>Folder: </span>
|
||||||
|
<span :class="'text-' + webdavFolderProps.color">
|
||||||
|
{{ webdavFolderProps.text }}
|
||||||
|
</span>
|
||||||
|
<p>
|
||||||
|
Last check:
|
||||||
|
{{
|
||||||
|
status?.webdav.last_check
|
||||||
|
? DateTime.fromISO(status.webdav.last_check).toLocaleString(
|
||||||
|
DateTime.DATETIME_MED
|
||||||
|
)
|
||||||
|
: "UNKNOWN"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Status } from "@/types/status";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status?: Status;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const webdavProps = computed(() => {
|
||||||
|
if (
|
||||||
|
props.status?.webdav.logged_in == undefined ||
|
||||||
|
props.status?.webdav.folder_created == undefined
|
||||||
|
) {
|
||||||
|
return { icon: "mdi-help-circle", text: "Unknown", color: "" };
|
||||||
|
} else if (
|
||||||
|
props.status?.webdav.logged_in &&
|
||||||
|
props.status?.webdav.folder_created
|
||||||
|
) {
|
||||||
|
return { icon: "mdi-check", text: "Ok", color: "green" };
|
||||||
|
} else {
|
||||||
|
return { icon: "mdi-alert", text: "Fail", color: "red" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const webdavLoggedProps = computed(() => {
|
||||||
|
if (props.status?.webdav.logged_in) {
|
||||||
|
return { text: "Ok", color: "green" };
|
||||||
|
} else {
|
||||||
|
return { text: "Fail", color: "red" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const webdavFolderProps = computed(() => {
|
||||||
|
if (props.status?.webdav.folder_created) {
|
||||||
|
return { text: "Ok", color: "green" };
|
||||||
|
} else {
|
||||||
|
return { text: "Fail", color: "red" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hassProps = computed(() => {
|
||||||
|
if (props.status?.hass.ok == undefined) {
|
||||||
|
return { icon: "mdi-help-circle", text: "Unknown", color: "" };
|
||||||
|
} else if (props.status?.hass.ok) {
|
||||||
|
return { icon: "mdi-check", text: "Ok", color: "green" };
|
||||||
|
} else {
|
||||||
|
return { icon: "mdi-alert", text: "Fail", color: "red" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<v-row class="mt-5 justify-space-around">
|
||||||
|
<v-col cols="12" lg="4" xxl="3">
|
||||||
|
<ConnectionStatus :status="status"></ConnectionStatus>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" lg="4" xxl="3">
|
||||||
|
<BackupStatus :status="status"></BackupStatus>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="12" lg="4" xxl="3">
|
||||||
|
<ActionComponent></ActionComponent>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getStatus } from "@/services/statusService";
|
||||||
|
import { States, Status } from "@/types/status";
|
||||||
|
import { computed, ref, onBeforeUnmount } from "vue";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import ConnectionStatus from "./ConnectionStatus.vue";
|
||||||
|
import BackupStatus from "./BackupStatus.vue";
|
||||||
|
import ActionComponent from "./ActionComponent.vue";
|
||||||
|
|
||||||
|
const status = ref<Status | undefined>(undefined);
|
||||||
|
|
||||||
|
let oldStatus: States | undefined = undefined;
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "stateUpdated", state: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
getStatus().then((data) => {
|
||||||
|
status.value = data;
|
||||||
|
if (oldStatus != status.value.status) {
|
||||||
|
oldStatus = status.value.status;
|
||||||
|
emit("stateUpdated", status.value.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshStatus();
|
||||||
|
const interval = setInterval(refreshStatus, 500);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
</script>
|
63
nextcloud_backup/frontend/src/composable/ConfigForm.ts
Normal file
63
nextcloud_backup/frontend/src/composable/ConfigForm.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
|
||||||
|
export function useConfigForm(
|
||||||
|
saveService: (data: any) => Promise<unknown>,
|
||||||
|
loadService: () => Promise<any>,
|
||||||
|
dataRef: Ref,
|
||||||
|
errorsRef: Ref,
|
||||||
|
emit: {
|
||||||
|
(e: "success"): void;
|
||||||
|
(e: "fail"): void;
|
||||||
|
(e: "loaded"): void;
|
||||||
|
(e: "loading"): void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
loading.value = true;
|
||||||
|
clearErrors();
|
||||||
|
saveService(dataRef.value)
|
||||||
|
.then(() => {
|
||||||
|
loading.value = false;
|
||||||
|
emit("success");
|
||||||
|
})
|
||||||
|
.catch(async (reason) => {
|
||||||
|
if (reason instanceof HTTPError) {
|
||||||
|
const response = await reason.response.json();
|
||||||
|
if (response["type"] == "validation") {
|
||||||
|
for (const elem of response["errors"]) {
|
||||||
|
errorsRef.value[
|
||||||
|
elem.context.key as keyof typeof errorsRef.value
|
||||||
|
] = elem.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (response["type"] == "cron") {
|
||||||
|
for (const elem of response["errors"]) {
|
||||||
|
errorsRef.value["cron"].push(elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
emit("fail");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
for (const elem in errorsRef.value) {
|
||||||
|
errorsRef.value[elem as keyof typeof errorsRef.value] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
emit("loading");
|
||||||
|
loadService().then(() => {
|
||||||
|
loading.value = false;
|
||||||
|
emit("loaded");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return { save, loading };
|
||||||
|
}
|
17
nextcloud_backup/frontend/src/composable/menuSize.ts
Normal file
17
nextcloud_backup/frontend/src/composable/menuSize.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { computed } from "vue";
|
||||||
|
import { useDisplay } from "vuetify/lib/framework.mjs";
|
||||||
|
|
||||||
|
export function useMenuSize() {
|
||||||
|
const { xs, mdAndDown } = useDisplay();
|
||||||
|
const width = computed(() => {
|
||||||
|
if (xs.value) {
|
||||||
|
return undefined;
|
||||||
|
} else if (mdAndDown.value) {
|
||||||
|
return "80%";
|
||||||
|
} else {
|
||||||
|
return "50%";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const isFullScreen = xs;
|
||||||
|
return { width, isFullScreen };
|
||||||
|
}
|
8
nextcloud_backup/frontend/src/env.d.ts
vendored
Normal file
8
nextcloud_backup/frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
9
nextcloud_backup/frontend/src/main.ts
Normal file
9
nextcloud_backup/frontend/src/main.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { registerPlugins } from "@/plugins";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
registerPlugins(app);
|
||||||
|
|
||||||
|
app.mount("#app");
|
20
nextcloud_backup/frontend/src/plugins/index.ts
Normal file
20
nextcloud_backup/frontend/src/plugins/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* plugins/index.ts
|
||||||
|
*
|
||||||
|
* Automatically included in `./src/main.ts`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import vuetify from "./vuetify";
|
||||||
|
import pinia from "../store";
|
||||||
|
// import router from '../router'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { App } from "vue";
|
||||||
|
|
||||||
|
export function registerPlugins(app: App) {
|
||||||
|
app
|
||||||
|
.use(vuetify)
|
||||||
|
// .use(router)
|
||||||
|
.use(pinia);
|
||||||
|
}
|
26
nextcloud_backup/frontend/src/plugins/vuetify.ts
Normal file
26
nextcloud_backup/frontend/src/plugins/vuetify.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Styles
|
||||||
|
import "@mdi/font/css/materialdesignicons.css";
|
||||||
|
import "vuetify/styles";
|
||||||
|
|
||||||
|
// Vuetify
|
||||||
|
import { createVuetify } from "vuetify";
|
||||||
|
|
||||||
|
const darkTheme = {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
primary: "#0091ea", //light-blue accent-4
|
||||||
|
accent: "#FF6F00", //amber darken-4
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createVuetify(
|
||||||
|
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||||
|
{
|
||||||
|
theme: {
|
||||||
|
defaultTheme: "darkTheme",
|
||||||
|
themes: {
|
||||||
|
darkTheme,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user