mirror of
https://github.com/Sebclem/hassio-nextcloud-backup.git
synced 2024-11-22 01:02:59 +01:00
commit
8a787d403e
147
.github/workflows/build_addon.yml
vendored
147
.github/workflows/build_addon.yml
vendored
@ -2,7 +2,7 @@ name: Build addon
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [published]
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
@ -10,17 +10,81 @@ env:
|
||||
TARGET: nextcloud_backup
|
||||
IMAGE: "hassio-nextcloud-backup"
|
||||
REPOSITORY: ghcr.io/sebclem
|
||||
IMAGE_SOURCE : https://github.com/Sebclem/hassio-nextcloud-backup
|
||||
|
||||
|
||||
IMAGE_SOURCE: https://github.com/Sebclem/hassio-nextcloud-backup
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [ aarch64, amd64, armv7, i386, armhf ]
|
||||
arch: [ aarch64, amd64, armv7 ]
|
||||
include:
|
||||
- arch: aarch64
|
||||
arch_value: linux/arm64/v8
|
||||
@ -28,39 +92,16 @@ jobs:
|
||||
arch_value: linux/amd64
|
||||
- arch: armv7
|
||||
arch_value: linux/arm/v7
|
||||
- arch: i386
|
||||
arch_value: linux/386
|
||||
- arch: armhf
|
||||
arch_value: linux/arm/v6
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx ${{matrix.arch}}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- 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
|
||||
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get build option ${{matrix.arch}}
|
||||
run: |
|
||||
@ -68,38 +109,48 @@ jobs:
|
||||
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 "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}}
|
||||
run: |
|
||||
if [ "${{env.version_type}}" != "dev" ]; then
|
||||
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
|
||||
else
|
||||
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
|
||||
fi
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}
|
||||
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
|
||||
run: |
|
||||
echo ${{env.VERSION}}
|
||||
echo ${{env.TAGS}}
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo "${{ steps.meta.outputs.labels }}"
|
||||
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push ${{matrix.arch}}
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{env.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
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
io.hass.version=${{steps.meta.outputs.version}}
|
||||
build-args: |
|
||||
BUILD_FROM=${{env.BUILD_FROM}}
|
||||
BUILD_VERSION=${{env.VERSION}}
|
||||
IMAGE_SOURCE=${{env.IMAGE_SOURCE}}
|
||||
file: ./${{env.TARGET}}/Dockerfile
|
||||
cache-from: type=registry,ref=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest
|
||||
cache-to: type=inline
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -103,7 +103,6 @@ dist
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
.vscode
|
||||
status.json
|
||||
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
|
||||
FROM ${BUILD_FROM}
|
||||
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.18
|
||||
|
||||
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/usr /usr/
|
||||
|
||||
# Setup base
|
||||
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/
|
||||
RUN apk add --no-cache nodejs-current && mkdir -p /usr/local/sbin/ && ln -s /usr/bin/node /usr/local/sbin/node
|
||||
|
||||
WORKDIR /opt/nextcloud_backup/
|
||||
|
||||
# Enable Yarn
|
||||
RUN corepack enable
|
||||
COPY backend/package.json backend/pnpm-lock.yaml ./
|
||||
|
||||
# Install packages
|
||||
RUN yarn install
|
||||
|
||||
# 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}
|
||||
COPY --from=backend-builder /app/dist .
|
||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||
COPY --from=frontend-builder /app/dist ./public
|
||||
|
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"
|
||||
}
|
||||
]
|
||||
}
|
@ -9,3 +9,5 @@
|
||||
- `7` => Fail to download snap
|
||||
- `8` => Fail to stop 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";
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
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
|
||||
function humanFileSize(bytes, si = false, dp = 1) {
|
||||
function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
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": [
|
||||
"aarch64",
|
||||
"amd64",
|
||||
"armhf",
|
||||
"armv7",
|
||||
"i386"
|
||||
"armv7"
|
||||
],
|
||||
"boot": "auto",
|
||||
"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