🔨 Add cron setting menu

This commit is contained in:
SebClem 2023-01-13 16:18:27 +01:00
parent 446e89a6c7
commit 1324c0b3d1
Signed by: sebclem
GPG Key ID: 5A4308F6A359EA50
29 changed files with 1304 additions and 477 deletions

View File

@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
app.use(cors({ app.use(cors({
origin: "http://localhost:5173" origin: true
})) }))
app.set("port", process.env.PORT || 3000); app.set("port", process.env.PORT || 3000);

View File

@ -1,17 +1,34 @@
import express from "express"; import express from "express";
import * as haOsService from "../services/homeAssistantService.js" import * as haOsService from "../services/homeAssistantService.js";
const homeAssistantRouter = express.Router(); const homeAssistantRouter = express.Router();
homeAssistantRouter.get("/backups/", (req, res, next) => { homeAssistantRouter.get("/backups/", (req, res, next) => {
haOsService.getBackups() haOsService
.then((value)=>{ .getBackups()
.then((value) => {
res.json(value.body.data.backups); res.json(value.body.data.backups);
}).catch((reason)=>{ })
.catch((reason) => {
res.status(500); res.status(500);
res.json(reason); res.json(reason);
}) });
}); });
homeAssistantRouter.get("/addons", (req, res, next) => {
haOsService
.getAddonList()
.then((value) => {
res.json(value.body.data);
})
.catch((reason) => {
res.status(500);
res.json(reason);
});
});
homeAssistantRouter.get("/folders", (req, res, next) => {
res.json(haOsService.getFolderList());
})
export default homeAssistantRouter; export default homeAssistantRouter;

View File

@ -86,30 +86,6 @@ function getAddonToBackup(addons: AddonModel[]) {
return slugs; return slugs;
} }
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 getFolderToBackup() { function getFolderToBackup() {
const excluded_folder = settingsTools.getSettings().exclude_folder; const excluded_folder = settingsTools.getSettings().exclude_folder;
@ -434,6 +410,32 @@ function startAddons(addonSlugs: string[]) {
}); });
} }
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(state: Status) { function publish_state(state: Status) {
// let data_error_sensor = { // let data_error_sensor = {
// state: state.status == "error" ? "on" : "off", // state: state.status == "error" ? "on" : "off",
@ -501,7 +503,6 @@ function publish_state(state: Status) {
export { export {
getVersion, getVersion,
getAddonList, getAddonList,
getFolderList,
getBackups, getBackups,
downloadSnapshot, downloadSnapshot,
createNewBackup, createNewBackup,

View File

@ -59,6 +59,7 @@ export function getWebdavDefaultConfig(): WebdavConfig {
password: "", password: "",
backupDir: default_root, backupDir: default_root,
allowSelfSignedCerts: false, allowSelfSignedCerts: false,
chunckedUpload: false,
webdavEndpoint: { webdavEndpoint: {
type: WebdavEndpointType.NEXTCLOUD, type: WebdavEndpointType.NEXTCLOUD,
}, },

View File

@ -47,8 +47,8 @@ const backupConfigValidation = {
webdav: Joi.object(AutoCleanConfig).required(), webdav: Joi.object(AutoCleanConfig).required(),
}).required(), }).required(),
exclude: Joi.object({ exclude: Joi.object({
addon: Joi.array().items(Joi.string().not().empty()), addon: Joi.array().items(Joi.string().not().empty()).required(),
folder: Joi.array().items(Joi.string().not().empty()), folder: Joi.array().items(Joi.string().not().empty()).required(),
}).required(), }).required(),
autoStopAddon: Joi.array().items(Joi.string().not().empty()), autoStopAddon: Joi.array().items(Joi.string().not().empty()),
password: Joi.object({ password: Joi.object({

View File

@ -10,6 +10,7 @@ export interface WebdavConfig {
password: string; password: string;
backupDir: string; backupDir: string;
allowSelfSignedCerts: boolean allowSelfSignedCerts: boolean
chunckedUpload: boolean
webdavEndpoint: { webdavEndpoint: {
type: WebdavEndpointType; type: WebdavEndpointType;
customEndpoint?: string; customEndpoint?: string;

View File

@ -3,11 +3,12 @@ import { WebdavEndpointType } from "./webdavConfig.js";
const WebdavConfigValidation = { const WebdavConfigValidation = {
url: Joi.string().not().empty().uri().required(), url: Joi.string().not().empty().uri().required().label("Url"),
username: Joi.string().not().empty().required(), username: Joi.string().not().empty().label("Username"),
password: Joi.string().not().empty().required(), password: Joi.string().not().empty().label("Password"),
backupDir: Joi.string().required(), backupDir: Joi.string().required().label("Backup directory"),
allowSelfSignedCerts: Joi.boolean().required(), allowSelfSignedCerts: Joi.boolean().label("Allow self signed certificate"),
chunckedUpload: Joi.boolean().required().label("Chuncked upload"),
webdavEndpoint: Joi.object({ webdavEndpoint: Joi.object({
type: Joi.string().valid(WebdavEndpointType.CUSTOM, WebdavEndpointType.NEXTCLOUD).required(), type: Joi.string().valid(WebdavEndpointType.CUSTOM, WebdavEndpointType.NEXTCLOUD).required(),
customEndpoint: Joi.alternatives().conditional("type", { customEndpoint: Joi.alternatives().conditional("type", {
@ -15,7 +16,7 @@ const WebdavConfigValidation = {
then: Joi.string().not().empty().required, then: Joi.string().not().empty().required,
otherwise: Joi.disallow() otherwise: Joi.disallow()
}) })
}).required() }).required().label("Webdav endpoint"),
} }
export default WebdavConfigValidation; export default WebdavConfigValidation;

View File

@ -11,33 +11,35 @@
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "5.9.55", "@mdi/font": "7.1.96",
"@mdi/js": "^7.0.96", "@mdi/js": "^7.1.96",
"@types/luxon": "^3.0.1", "@types/luxon": "^3.2.0",
"@types/uuid": "^9.0.0",
"ky": "^0.31.4", "ky": "^0.31.4",
"luxon": "^3.0.4", "luxon": "^3.2.1",
"pinia": "^2.0.23", "pinia": "^2.0.28",
"pretty-bytes": "^6.0.0", "pretty-bytes": "^6.0.0",
"roboto-fontface": "*", "roboto-fontface": "*",
"vue": "^3.2.40", "uuid": "^9.0.0",
"vuetify": "3.0.1", "vue": "^3.2.45",
"webfontloader": "^1.0.0" "vuetify": "3.1.1",
"webfontloader": "^1.6.28"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.2.0", "@rushstack/eslint-patch": "^1.2.0",
"@types/node": "^16.11.65", "@types/node": "^16.18.11",
"@types/webfontloader": "^1.0.0", "@types/webfontloader": "^1.6.35",
"@vitejs/plugin-vue": "^3.1.2", "@vitejs/plugin-vue": "^3.2.0",
"@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.2", "@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"eslint": "^8.25.0", "eslint": "^8.31.0",
"eslint-plugin-vue": "^9.6.0", "eslint-plugin-vue": "^9.8.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^2.7.1", "prettier": "^2.8.2",
"typescript": "~4.7.4", "typescript": "~4.7.4",
"vite": "^3.1.7", "vite": "^3.2.5",
"vite-plugin-vuetify": "^1.0.0-alpha.12", "vite-plugin-vuetify": "^1.0.1",
"vue-tsc": "1.0.7" "vue-tsc": "1.0.7"
}, },
"packageManager": "pnpm@7.12.1" "packageManager": "pnpm@7.12.1"

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
<navbar-component></navbar-component> <navbar-component></navbar-component>
<message-bar></message-bar> <message-bar></message-bar>
<webdav-settings-menu></webdav-settings-menu> <webdav-settings-menu></webdav-settings-menu>
<BackupConfigMenu></BackupConfigMenu>
<v-main class="mx-12"> <v-main class="mx-12">
<v-row> <v-row>
<v-col cols="6" offset="6"> <v-col cols="6" offset="6">
@ -18,6 +19,7 @@ import NavbarComponent from "./components/NavbarComponent.vue";
import MessageBar from "./components/MessageBar.vue"; import MessageBar from "./components/MessageBar.vue";
import WebdavSettingsMenu from "./components/settings/WebdavConfigMenu.vue"; import WebdavSettingsMenu from "./components/settings/WebdavConfigMenu.vue";
import CloudList from "./components/cloud/CloudList.vue"; import CloudList from "./components/cloud/CloudList.vue";
import BackupConfigMenu from "./components/settings/BackupConfigMenu.vue";
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -6,11 +6,15 @@
<v-card-text> <v-card-text>
<v-row> <v-row>
<v-col> <v-col>
<v-card variant="outlined" elevation="5" color="grey-darken-2"> <v-card variant="elevated" elevation="7" height="100%">
<v-card-title class="text-center text-white">Auto</v-card-title> <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-divider color="grey-darken-3"></v-divider>
<v-card-text class="pa-0"> <v-card-text class="pa-0">
<v-list variant="tonal" class="pa-0"> <v-list class="pa-0">
<v-list-item <v-list-item
v-if="autoBackups.length == 0" v-if="autoBackups.length == 0"
class="text-center text-subtitle-2 text-disabled" class="text-center text-subtitle-2 text-disabled"
@ -31,11 +35,14 @@
</v-row> </v-row>
<v-row> <v-row>
<v-col> <v-col>
<v-card variant="outlined" elevation="5" color="grey-darken-2"> <v-card variant="elevated" elevation="7" height="100%">
<v-card-title class="text-center text-white">Manual</v-card-title> <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-divider color="grey-darken-3"></v-divider>
<v-card-text class="pa-0"> <v-card-text class="pa-0">
<v-list variant="tonal" class="pa-0"> <v-list class="pa-0">
<v-list-item <v-list-item
v-if="manualBackups.length == 0" v-if="manualBackups.length == 0"
class="text-center text-subtitle-2 text-disabled" class="text-center text-subtitle-2 text-disabled"

View File

@ -1,6 +1,6 @@
<template> <template>
<v-divider v-if="index != 0" color="grey-darken-3"></v-divider> <v-divider v-if="index != 0" color="grey-darken-3"></v-divider>
<v-list-item> <v-list-item class="bg-grey-darken-3">
<v-list-item-title>{{ item.name }}</v-list-item-title> <v-list-item-title>{{ item.name }}</v-list-item-title>
<template v-slot:append> <template v-slot:append>
<v-scroll-x-transition> <v-scroll-x-transition>

View File

@ -0,0 +1,46 @@
<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 "@/stores/backupConfig";
import { storeToRefs } from "pinia";
import { watch } from "vue";
defineProps<{ loading: boolean }>();
const backupConfigStore = useBackupConfigStore();
const { data, addons, invertedAddons } = storeToRefs(backupConfigStore);
watch(invertedAddons, () => {
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>

View File

@ -0,0 +1,194 @@
<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 leave-absolute>
<v-chip
v-if="!expanded && cron.monthDay != undefined"
append-icon="mdi-calendar"
size="small"
>
{{ cron.monthDay }}
</v-chip>
</v-fade-transition>
<v-fade-transition leave-absolute>
<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 leave-absolute>
<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 leave-absolute>
<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>
</v-expansion-panels>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { useBackupConfigStore } from "@/stores/backupConfig";
import {
CronMode,
CronModeFriendly,
weekdayFriendly,
} from "@/types/backupConfig";
import { storeToRefs } from "pinia";
import { ref } from "vue";
const expansionPanelModel = ref(undefined);
defineProps<{ loading: boolean }>();
const backupConfigStore = useBackupConfigStore();
function removeCron(id: string) {
expansionPanelModel.value = undefined;
backupConfigStore.removeCron(id);
}
const { data } = storeToRefs(backupConfigStore);
</script>
<style scoped></style>

View File

@ -0,0 +1,46 @@
<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 "@/stores/backupConfig";
import { storeToRefs } from "pinia";
import { watch } from "vue";
defineProps<{ loading: boolean }>();
const backupConfigStore = useBackupConfigStore();
const { data, folders, invertedFolders } = storeToRefs(backupConfigStore);
watch(invertedFolders, () => {
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>

View File

@ -0,0 +1,94 @@
<template>
<v-form class="mx-4">
<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.config.nameTemplate"
:error-messages="errors.nameTemplate"
:loading="loading"
color="orange"
>
<template v-slot:append>
<v-btn
color="success"
variant="outlined"
class="mt-n2"
height="auto"
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 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-divider class="my-4"></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>
<v-col>
<BackupConfigAutoBackup :loading="loading"></BackupConfigAutoBackup>
</v-col>
</v-row>
</v-form>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useConfigForm } from "@/composable/ConfigForm";
import { saveBackupConfig } from "@/services/configService";
import { useBackupConfigStore } from "@/stores/backupConfig";
import { CronMode } from "@/types/backupConfig";
import { storeToRefs } from "pinia";
import BackupConfigAddon from "./BackupConfig/BackupConfigAddon.vue";
import BackupConfigFolder from "./BackupConfig/BackupConfigFolder.vue";
import BackupConfigAutoBackup from "./BackupConfig/BackupConfigAutoBackup.vue";
const backupConfigStore = useBackupConfigStore();
const { data, folders, invertedFolders } = storeToRefs(backupConfigStore);
const errors = ref({
nameTemplate: [],
username: [],
password: [],
backupDir: [],
allowSelfSignedCerts: [],
type: [],
customEndpoint: [],
});
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,62 @@
<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></v-divider>
<v-card-text>
<backup-config-form
ref="form"
@fail="saving = false"
@success="saved"
@loaded="loading = false"
@loading="loading = true"
></backup-config-form>
</v-card-text>
<v-divider></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 "@/stores/dialogStatus";
import { computed, ref } from "vue";
import { useMenuSize } from "@/composable/menuSize";
import BackupConfigForm from "./BackupConfigForm.vue";
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 saved() {
dialogStatusStore.webdav = false;
saving.value = false;
}
</script>

View File

@ -48,7 +48,7 @@
></v-text-field> ></v-text-field>
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row class="mt-0">
<v-col class="d-flex align-content-end"> <v-col class="d-flex align-content-end">
<v-switch <v-switch
label="Allow Self Signed Certificate" label="Allow Self Signed Certificate"
@ -62,7 +62,21 @@
></v-switch> ></v-switch>
</v-col> </v-col>
</v-row> </v-row>
<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-col><v-divider></v-divider></v-col>
</v-row> </v-row>
<v-row> <v-row>
@ -133,18 +147,10 @@
</v-form> </v-form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { WebdavEndpointType, type WebdavConfig } from "@/types/webdavConfig";
WebdavEndpointType, import { getWebdavConfig, saveWebdavConfig } from "@/services/configService";
type WebdavConfig,
} from "../../types/webdavConfig";
import {
getWebdavConfig,
saveWebdavConfig,
} from "../../services/ConfigService";
import { ref } from "vue"; import { ref } from "vue";
import { HTTPError } from "ky"; import { useConfigForm } from "@/composable/ConfigForm";
const loading = ref(true);
const items = [ const items = [
{ {
@ -165,6 +171,7 @@ const errors = ref({
allowSelfSignedCerts: [], allowSelfSignedCerts: [],
type: [], type: [],
customEndpoint: [], customEndpoint: [],
chunckedUpload: [],
}); });
const data = ref<WebdavConfig>({ const data = ref<WebdavConfig>({
@ -173,6 +180,7 @@ const data = ref<WebdavConfig>({
backupDir: "", backupDir: "",
username: "", username: "",
password: "", password: "",
chunckedUpload: false,
webdavEndpoint: { webdavEndpoint: {
type: WebdavEndpointType.NEXTCLOUD, type: WebdavEndpointType.NEXTCLOUD,
}, },
@ -185,44 +193,19 @@ const emit = defineEmits<{
(e: "loading"): void; (e: "loading"): void;
}>(); }>();
function save() {
loading.value = true;
clearErrors();
saveWebdavConfig(data.value)
.then(() => {
emit("success");
loading.value = false;
})
.catch(async (reason) => {
if (reason instanceof HTTPError) {
const response = await reason.response.json();
if (Array.isArray(response)) {
for (let elem of response) {
errors.value[elem.context.key as keyof typeof errors.value] =
elem.message;
}
}
}
emit("fail");
loading.value = false;
});
}
function clearErrors() {
for (let elem in errors.value) {
errors.value[elem as keyof typeof errors.value] = [];
}
}
function loadData() { function loadData() {
emit("loading"); return getWebdavConfig().then((value) => {
getWebdavConfig().then((value) => {
data.value = value; data.value = value;
emit("loaded"); return data;
loading.value = false;
}); });
} }
loadData(); const { save, loading } = useConfigForm(
saveWebdavConfig,
loadData,
data,
errors,
emit
);
defineExpose({ save }); defineExpose({ save });
</script> </script>

View File

@ -3,7 +3,7 @@
v-model="dialogStatusStore.webdav" v-model="dialogStatusStore.webdav"
persistent persistent
:width="width" :width="width"
:fullscreen="xs" :fullscreen="isFullScreen"
scrollable scrollable
> >
<v-card> <v-card>
@ -13,7 +13,7 @@
<webdav-settings-form <webdav-settings-form
ref="form" ref="form"
@fail="saving = false" @fail="saving = false"
@success="dialogStatusStore.webdav = false" @success="saved"
@loaded="loading = false" @loaded="loading = false"
@loading="loading = true" @loading="loading = true"
></webdav-settings-form> ></webdav-settings-form>
@ -35,33 +35,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMenuSize } from "@/composable/menuSize";
import { useDialogStatusStore } from "@/stores/dialogStatus"; import { useDialogStatusStore } from "@/stores/dialogStatus";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useDisplay } from "vuetify/lib/framework.mjs";
import WebdavSettingsForm from "./WebdavConfigForm.vue"; import WebdavSettingsForm from "./WebdavConfigForm.vue";
const dialogStatusStore = useDialogStatusStore(); const dialogStatusStore = useDialogStatusStore();
const form = ref<InstanceType<typeof WebdavSettingsForm> | null>(null); const form = ref<InstanceType<typeof WebdavSettingsForm> | null>(null);
const { width, isFullScreen } = useMenuSize();
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
const { xs, mdAndDown } = useDisplay();
let saveLoading = computed(() => { let saveLoading = computed(() => {
return saving.value || loading.value; return saving.value || loading.value;
}); });
const width = computed(() => {
if (xs.value) {
return undefined;
} else if (mdAndDown.value) {
return "80%";
} else {
return "50%";
}
});
function save() { function save() {
saving.value = true; saving.value = true;
form.value?.save(); form.value?.save();
} }
function saved() {
dialogStatusStore.webdav = false;
saving.value = false;
}
</script> </script>

View File

@ -0,0 +1,58 @@
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 (Array.isArray(response)) {
for (const elem of response) {
errorsRef.value[
elem.context.key as keyof typeof errorsRef.value
] = elem.message;
}
}
}
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

@ -1,3 +1,4 @@
import type { BackupConfig } from "@/types/backupConfig";
import type { WebdavConfig } from "@/types/webdavConfig"; import type { WebdavConfig } from "@/types/webdavConfig";
import kyClient from "./kyClient"; import kyClient from "./kyClient";
@ -12,3 +13,15 @@ export function saveWebdavConfig(config: WebdavConfig) {
}) })
.json(); .json();
} }
export function getBackupConfig() {
return kyClient.get("config/backup").json<BackupConfig>();
}
export function saveBackupConfig(config: BackupConfig) {
return kyClient
.put("config/backup", {
json: config,
})
.json();
}

View File

@ -0,0 +1,10 @@
import type { AddonData, Folder } from "@/types/homeAssistant";
import kyClient from "./kyClient";
export function getFolders() {
return kyClient.get("homeAssistant/folders").json<Folder[]>();
}
export function getAddons() {
return kyClient.get("homeAssistant/addons").json<AddonData>();
}

View File

@ -0,0 +1,60 @@
import { getBackupConfig } from "@/services/configService";
import { getAddons, getFolders } from "@/services/homeAssistantService";
import { CronMode, type BackupConfig } from "@/types/backupConfig";
import type { Folder, AddonModel } from "@/types/homeAssistant";
import { defineStore } from "pinia";
import { ref } from "vue";
import { v4 as uuidv4 } from "uuid";
export const useBackupConfigStore = defineStore("backupConfig", () => {
const data = ref<BackupConfig>({} as BackupConfig);
const addons = ref<AddonModel[]>([]);
const folders = ref<Folder[]>([]);
// This represent the oposite of excluded => if prensent on this list, backup it
const invertedFolders = ref<string[]>([]);
const invertedAddons = ref<string[]>([]);
function loadAll() {
const conf = getBackupConfig();
const foldersProm = getFolders();
const addonsProm = getAddons();
return Promise.all([conf, foldersProm, addonsProm]).then((value) => {
for (const folder of value[1]) {
if (!value[0].exclude.folder.includes(folder.slug)) {
invertedFolders.value.push(folder.slug);
}
}
for (const addon of value[2].addons) {
if (!value[0].exclude.addon.includes(addon.slug)) {
invertedAddons.value.push(addon.slug);
}
}
data.value = value[0];
folders.value = value[1];
addons.value = value[2].addons;
});
}
function addEmptyCron() {
data.value.cron.push({
id: uuidv4(),
mode: CronMode.Daily,
});
}
function removeCron(id: string) {
data.value.cron = data.value.cron.filter((value) => value.id != id);
}
return {
data,
addons,
folders,
invertedFolders,
invertedAddons,
loadAll,
addEmptyCron,
removeCron,
};
});

View File

@ -0,0 +1,65 @@
export enum CronMode {
Daily = "DAILY",
Weekly = "WEEKLY",
Monthly = "MONTHLY",
Custom = "CUSTOM",
}
export enum CronModeFriendly {
DAILY = "Daily",
WEEKLY = "Weekly",
MONTHLY = "Monthly",
CUSTOM = "Custom",
}
export enum Weekday {
SUNDAY,
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
}
export const weekdayFriendly = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
export interface BackupConfig {
nameTemplate: string;
cron: CronConfig[];
autoClean: {
homeAssistant: AutoCleanConfig;
webdav: AutoCleanConfig;
};
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,24 @@
export interface Folder {
name: string;
slug: 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;
}

View File

@ -9,6 +9,7 @@ export interface WebdavConfig {
password: string; password: string;
backupDir: string; backupDir: string;
allowSelfSignedCerts: boolean; allowSelfSignedCerts: boolean;
chunckedUpload: boolean;
webdavEndpoint: { webdavEndpoint: {
type: WebdavEndpointType; type: WebdavEndpointType;
customEndpoint?: string; customEndpoint?: string;

View File

@ -5,7 +5,8 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} },
"lib": ["ES2017", "DOM", "DOM.Iterable"]
}, },
"references": [ "references": [

View File

@ -2,10 +2,14 @@ import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import dns from "dns";
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin // https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
import vuetify from "vite-plugin-vuetify"; import vuetify from "vite-plugin-vuetify";
// //Print localhost instead of 127.0.0.1
// dns.setDefaultResultOrder("verbatim");
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), vuetify({ autoImport: true })], plugins: [vue(), vuetify({ autoImport: true })],