Merge pull request #242 from Sebclem/vue

Vue
This commit is contained in:
Sébastien Clément 2024-08-13 17:10:56 +02:00 committed by GitHub
commit 8a787d403e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
152 changed files with 12840 additions and 16704 deletions

View File

@ -2,7 +2,7 @@ name: Build addon
on: on:
release: release:
types: [ published ] types: [published]
push: push:
workflow_dispatch: workflow_dispatch:
@ -10,57 +10,98 @@ env:
TARGET: nextcloud_backup TARGET: nextcloud_backup
IMAGE: "hassio-nextcloud-backup" IMAGE: "hassio-nextcloud-backup"
REPOSITORY: ghcr.io/sebclem REPOSITORY: ghcr.io/sebclem
IMAGE_SOURCE : https://github.com/Sebclem/hassio-nextcloud-backup IMAGE_SOURCE: https://github.com/Sebclem/hassio-nextcloud-backup
jobs: jobs:
build-front:
name: Build Front
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: nextcloud_backup/frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm i
working-directory: nextcloud_backup/frontend
- name: Build
run: pnpm build
working-directory: nextcloud_backup/frontend
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: front_dist
path: nextcloud_backup/frontend/dist/
build-back:
name: Build back
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: nextcloud_backup/backend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm i
working-directory: nextcloud_backup/backend
- name: Lint
run: pnpm lint
working-directory: nextcloud_backup/backend
- name: Build
run: pnpm build
working-directory: nextcloud_backup/backend
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: back_dist
path: nextcloud_backup/backend/dist/
build-dockers: build-dockers:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
arch: [ aarch64, amd64, armv7, i386, armhf ] arch: [ aarch64, amd64, armv7 ]
include: include:
- arch: aarch64 - arch: aarch64
arch_value: linux/arm64/v8 arch_value: linux/arm64/v8
- arch: amd64 - arch: amd64
arch_value: linux/amd64 arch_value: linux/amd64
- arch: armv7 - arch: armv7
arch_value: linux/arm/v7 arch_value: linux/arm/v7
- arch: i386
arch_value: linux/386
- arch: armhf
arch_value: linux/arm/v6
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx ${{matrix.arch}} - name: Set up Docker Buildx ${{matrix.arch}}
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Set Version Prod ${{matrix.arch}}
if: github.event_name != 'workflow_dispatch' && github.event_name != 'push'
run: |
version=${GITHUB_REF/refs\/tags\//}
if [ -n "$version" ];then
tmp=$(mktemp)
jq --arg version "$version" '.version=$version' ${{env.TARGET}}/config.json > "$tmp" && mv "$tmp" ${{env.TARGET}}/config.json
fi
echo "version_type=prod" >> $GITHUB_ENV
- name: Set Version Test ${{matrix.arch}}
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
run: |
version=dev_${GITHUB_RUN_ID}
if [ -n "$version" ];then
tmp=$(mktemp)
jq --arg version "$version" '.version=$version' ${{env.TARGET}}/config.json > "$tmp" && mv "$tmp" ${{env.TARGET}}/config.json
fi
echo "version_type=dev" >> $GITHUB_ENV
- name: Get build option ${{matrix.arch}} - name: Get build option ${{matrix.arch}}
run: | run: |
@ -68,38 +109,48 @@ jobs:
echo "DESCRIPTION=$(jq --raw-output '.description // empty' "${{env.TARGET}}/config.json" | sed "s/'//g")" >> $GITHUB_ENV echo "DESCRIPTION=$(jq --raw-output '.description // empty' "${{env.TARGET}}/config.json" | sed "s/'//g")" >> $GITHUB_ENV
echo "URL=$(jq --raw-output '.url // empty' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV echo "URL=$(jq --raw-output '.url // empty' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
echo "VERSION=$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV echo "VERSION=$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV
echo "BUILD_FROM=ghcr.io/hassio-addons/base/${{matrix.arch}}:$(cat nextcloud_backup/.base_version)" >> $GITHUB_ENV echo "BUILD_FROM=ghcr.io/home-assistant/${{matrix.arch}}-base:$(cat nextcloud_backup/.base_version)" >> $GITHUB_ENV
- name: Set Tags ${{matrix.arch}} - name: Docker meta
run: | id: meta
if [ "${{env.version_type}}" != "dev" ]; then uses: docker/metadata-action@v5
echo "TAGS=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest, ${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV with:
else images: |
echo "TAGS=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:dev, ${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:$(jq --raw-output '.version' "${{env.TARGET}}/config.json")" >> $GITHUB_ENV ${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}
fi tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
labels: |
io.hass.name=${{env.NAME}}
io.hass.description=${{env.DESCRIPTION}}
io.hass.url=${{env.URL}}
io.hass.arch=${{matrix.arch}}
io.hass.type=addon
- name: Debug Env - name: Debug Env
run: | run: |
echo ${{env.VERSION}} echo "${{ steps.meta.outputs.tags }}"
echo ${{env.TAGS}} echo "${{ steps.meta.outputs.labels }}"
- name: Login to ghcr.io - name: Login to ghcr.io
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{matrix.arch}} - name: Build and push ${{matrix.arch}}
uses: docker/build-push-action@v3 uses: docker/build-push-action@v6
with: with:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{env.TAGS}} tags: ${{ steps.meta.outputs.tags }}
labels: io.hass.name=${{env.NAME}}, io.hass.description=${{env.DESCRIPTION}}, io.hass.url=${{env.URL}}, io.hass.arch=${{matrix.arch}}, io.hass.version=${{env.VERSION}}, io.hass.type=addon labels: |
${{ steps.meta.outputs.labels }}
io.hass.version=${{steps.meta.outputs.version}}
build-args: | build-args: |
BUILD_FROM=${{env.BUILD_FROM}} BUILD_FROM=${{env.BUILD_FROM}}
BUILD_VERSION=${{env.VERSION}}
IMAGE_SOURCE=${{env.IMAGE_SOURCE}}
file: ./${{env.TARGET}}/Dockerfile file: ./${{env.TARGET}}/Dockerfile
cache-from: type=registry,ref=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest cache-from: type=registry,ref=${{env.REPOSITORY}}/${{env.IMAGE}}/${{matrix.arch}}:latest
cache-to: type=inline cache-to: type=inline

1
.gitignore vendored
View File

@ -103,7 +103,6 @@ dist
# TernJS port file # TernJS port file
.tern-port .tern-port
.vscode
status.json status.json
conf.json conf.json
webdav_conf.json webdav_conf.json

File diff suppressed because one or more lines are too long

View File

@ -1,3 +0,0 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.2.2.cjs

5
deploy
View File

@ -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

View File

@ -1 +1 @@
12.2.2 3.18

View File

@ -1,55 +1,38 @@
ARG BUILD_FROM=ghcr.io/hassio-addons/base/amd64:12.2.2 ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.18
FROM ${BUILD_FROM}
FROM node:20 AS frontend-builder
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install
COPY frontend/ .
RUN pnpm build
FROM node:20 AS backend-builder
WORKDIR /app
COPY backend/package.json backend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install
COPY backend/ .
RUN pnpm build
FROM $BUILD_FROM
# Copy root filesystem
COPY rootfs/etc /etc/ COPY rootfs/etc /etc/
COPY rootfs/usr /usr/ COPY rootfs/usr /usr/
# Setup base RUN apk add --no-cache nodejs-current && mkdir -p /usr/local/sbin/ && ln -s /usr/bin/node /usr/local/sbin/node
RUN apk add --no-cache nodejs-current
# Fix for arm/v7
RUN mkdir -p /usr/local/sbin/
RUN ln -s /usr/bin/node /usr/local/sbin/node
# Copy only package*.json
COPY rootfs/opt/nextcloud_backup/package*.json /opt/nextcloud_backup/
COPY rootfs/opt/nextcloud_backup/.yarnrc.yml /opt/nextcloud_backup/
COPY rootfs/opt/nextcloud_backup/.yarn/releases/* /opt/nextcloud_backup/.yarn/releases/
WORKDIR /opt/nextcloud_backup/ WORKDIR /opt/nextcloud_backup/
# Enable Yarn COPY backend/package.json backend/pnpm-lock.yaml ./
RUN corepack enable
# Install packages COPY --from=backend-builder /app/dist .
RUN yarn install COPY --from=backend-builder /app/node_modules ./node_modules
COPY --from=frontend-builder /app/dist ./public
# Copy all source code
COPY rootfs/opt/ /opt/
# Build arguments
ARG BUILD_ARCH
ARG BUILD_DATE
ARG BUILD_REF
ARG BUILD_VERSION
ARG IMAGE_SOURCE
# Labels
LABEL \
io.hass.name="Nextcloud Backup" \
io.hass.description="Addon that backup your snapshot to a Nextcloud server" \
io.hass.arch="${BUILD_ARCH}" \
io.hass.type="addon" \
io.hass.version=${BUILD_VERSION} \
maintainer="Sebclem" \
org.label-schema.description="Addon that backup your snapshot to a Nextcloud server" \
org.label-schema.build-date=${BUILD_DATE} \
org.label-schema.name="Nextcloud Backup" \
org.label-schema.schema-version="1.0" \
org.label-schema.url="https://addons.community" \
org.label-schema.usage="https://github.com/hassio-addons/addon-example/tree/master/README.md" \
org.label-schema.vcs-ref=${BUILD_REF} \
org.label-schema.vcs-url="https://github.com/hassio-addons/addon-example" \
org.label-schema.vendor="Sebclem"\
org.opencontainers.image.source=${IMAGE_SOURCE}

5
nextcloud_backup/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
dist/**
.vscode/*
!.vscode/settings.json
!.vscode/launch.json
!.vscode/tasks.json

View 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"
}
]
}

View 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
}

View 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"
}
]
}

View File

@ -8,4 +8,6 @@
- `6` => Fail to clean - `6` => Fail to clean
- `7` => Fail to download snap - `7` => Fail to download snap
- `8` => Fail to stop addon - `8` => Fail to stop addon
- `9` => Fail to start addon - `9` => Fail to start addon

View 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"]
}
);

View 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"
}
}

File diff suppressed because it is too large Load Diff

View 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;

View File

@ -1,5 +1,4 @@
import winston from "winston"; import winston from "winston";
const logger = winston.createLogger({ const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine( format: winston.format.combine(

View File

@ -0,0 +1,2 @@
import dotenv from "dotenv";
dotenv.config();

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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

View 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;

View 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;

View 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;
}

View 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
);
}

View 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,
};

View 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}
`
);
}
});
}

View 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,
},
};
}

View 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);
}
);
}

View 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;

View 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;

View 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);
}
}

View File

@ -1,5 +1,5 @@
// Found on Stackoverflow, perfect code :D https://stackoverflow.com/a/14919494/8654475 // Found on Stackoverflow, perfect code :D https://stackoverflow.com/a/14919494/8654475
function humanFileSize(bytes, si = false, dp = 1) { function humanFileSize(bytes: number, si = false, dp = 1) {
const thresh = si ? 1000 : 1024; const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) { if (Math.abs(bytes) < thresh) {

View 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;
}

View 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;
}

View File

@ -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;

View File

@ -0,0 +1,8 @@
export interface NewBackupPayload {
name?: string;
password?: string;
homeassistant?: boolean;
addons?: string[];
folders?: string[];
compressed?: boolean;
}

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
export enum WorkflowType {
AUTO,
MANUAL,
}

View 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;
}

View 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;
};
}

View File

@ -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;

View File

@ -0,0 +1,5 @@
import Joi from "joi";
export const WebdavDeleteValidation = {
path: Joi.string().not().empty().required()
}

View 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;
}

View 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;
};
}

View 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/**/*"]
}

View File

@ -16,9 +16,7 @@
"arch": [ "arch": [
"aarch64", "aarch64",
"amd64", "amd64",
"armhf", "armv7"
"armv7",
"i386"
], ],
"boot": "auto", "boot": "auto",
"hassio_api": true, "hassio_api": true,

View File

@ -0,0 +1 @@
VITE_API_URL="http://localhost:3000/"

View File

@ -0,0 +1 @@
VITE_API_URL="./"

View 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
}
}

View 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
View 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?

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View 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
```

View 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']>
}
}

View 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']
}
}

View 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>

View 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"
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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>

View 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;
}

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View 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;
}
}

View 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

View 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>

View 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

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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 };
}

View 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 };
}

View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View 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");

View 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);
}

View 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