mirror of
https://github.com/azukaar/Cosmos-Server.git
synced 2025-12-21 12:09:31 -06:00
[release] v0.18.0-unstable7
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,4 +27,4 @@ restic-arm
|
||||
rclone
|
||||
rclone-arm
|
||||
test-backup
|
||||
backups
|
||||
/backups
|
||||
@@ -3,6 +3,8 @@
|
||||
- Implements sudo mode - your normal token last longer, but you need to "sudo" to do admin tasks
|
||||
- Re-Implements the SSO using openID internally - fixes issue where you need to re-loging when app are on different domains (because of browser cookies limitations)
|
||||
- Implements local HTTPS Certificate Authority, to locally trust self-signed certificates on devices
|
||||
- Added new folder button to file picker
|
||||
- Fixed bug with RClone storage duplication in the UI
|
||||
- Implements hybrid HTTPS with public and self-signed certificates switched on the fly
|
||||
- OpenID now returns more info in case of errors when Cosmos is in debug mode
|
||||
- Localizations improvements (Thanks @madejackson)
|
||||
|
||||
@@ -25,6 +25,24 @@ function listSnapshots(name: string) {
|
||||
}))
|
||||
}
|
||||
|
||||
function listSnapshotsFromRepo(name: string) {
|
||||
return wrap(fetch(`/cosmos/api/backups-repository/${name}/snapshots`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function listRepo() {
|
||||
return wrap(fetch(`/cosmos/api/backups-repository`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function listFolders(name: string, snapshot: string, path?: string) {
|
||||
return wrap(fetch(`/cosmos/api/backups/${name}/${snapshot}/folders?path=${path || '/'}`, {
|
||||
method: 'GET',
|
||||
@@ -54,6 +72,16 @@ function addBackup(config: BackupConfig) {
|
||||
}))
|
||||
}
|
||||
|
||||
function editBackup(config: BackupConfig) {
|
||||
return wrap(fetch('/cosmos/api/backups/edit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
}))
|
||||
}
|
||||
|
||||
function removeBackup(name: string, deleteRepo: boolean = false) {
|
||||
return wrap(fetch(`/cosmos/api/backups/${name}`, {
|
||||
method: 'DELETE',
|
||||
@@ -64,6 +92,51 @@ function removeBackup(name: string, deleteRepo: boolean = false) {
|
||||
}))
|
||||
}
|
||||
|
||||
function forgetSnapshot(name: string, snapshot: string, deleteRepo: boolean = false) {
|
||||
return wrap(fetch(`/cosmos/api/backups/${name}/${snapshot}/forget`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ deleteRepo })
|
||||
}))
|
||||
}
|
||||
|
||||
function subfolderRestoreSize(name: string, snapshot: string, path: string) {
|
||||
return wrap(fetch(`/cosmos/api/backups/${name}/${snapshot}/subfolder-restore-size?path=` +encodeURIComponent(path), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function backupNow(name: string) {
|
||||
return wrap(fetch('/cosmos/api/jobs/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scheduler: "Restic",
|
||||
name: "Restic backup " + name,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function forgetNow(name: string) {
|
||||
return wrap(fetch('/cosmos/api/jobs/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scheduler: "Restic",
|
||||
name: "Restic forget " + name,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
export {
|
||||
listSnapshots,
|
||||
listFolders,
|
||||
@@ -71,5 +144,12 @@ export {
|
||||
addBackup,
|
||||
removeBackup,
|
||||
BackupConfig,
|
||||
RestoreConfig
|
||||
RestoreConfig,
|
||||
listRepo,
|
||||
listSnapshotsFromRepo,
|
||||
forgetSnapshot,
|
||||
editBackup,
|
||||
backupNow,
|
||||
forgetNow,
|
||||
subfolderRestoreSize
|
||||
};
|
||||
@@ -68,6 +68,15 @@ function deleteJob(name) {
|
||||
}))
|
||||
}
|
||||
|
||||
function runningJobs() {
|
||||
return wrap(fetch('/cosmos/api/jobs/running', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export {
|
||||
listen,
|
||||
list,
|
||||
@@ -75,4 +84,5 @@ export {
|
||||
stop,
|
||||
get,
|
||||
deleteJob,
|
||||
runningJobs
|
||||
}
|
||||
|
||||
@@ -255,10 +255,20 @@ const listDir = (storage, path) => {
|
||||
}))
|
||||
}
|
||||
|
||||
const newFolder = (storage, path, folder) => {
|
||||
return wrap(fetch(`/cosmos/api/new-dir?storage=${storage}&path=${path}&folder=${folder}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export {
|
||||
mounts,
|
||||
disks,
|
||||
snapRAID,
|
||||
raid,
|
||||
newFolder,
|
||||
listDir,
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { FolderOpenOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutli
|
||||
import * as API from '../api';
|
||||
import { current } from '@reduxjs/toolkit';
|
||||
import { json } from 'react-router';
|
||||
import { NewFolderButton } from './newFileModal';
|
||||
|
||||
function transformToTree(data) {
|
||||
const root = {
|
||||
@@ -57,7 +58,7 @@ function transformToTree(data) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const FilePickerModal = ({ raw, open, cb, OnClose, onPick, _storage = '', _path = '', select='any' }) => {
|
||||
const FilePickerModal = ({ raw, open, cb, OnClose, onPick, canCreate, _storage = '', _path = '', select='any' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedStorage, setSelectedStorage] = React.useState(_storage);
|
||||
const [selectedPath, setSelectedPath] = React.useState(_path);
|
||||
@@ -68,11 +69,11 @@ const FilePickerModal = ({ raw, open, cb, OnClose, onPick, _storage = '', _path
|
||||
|
||||
let explore = !onPick;
|
||||
|
||||
const updateFiles = (storage, path) => {
|
||||
const updateFiles = (storage, path, force) => {
|
||||
let resetStorage = storage != selectedStorage;
|
||||
|
||||
// if already loaded, just update the tree
|
||||
if(files[path]) {
|
||||
if(!force && files[path]) {
|
||||
setSelectedStorage(storage);
|
||||
setSelectedPath(path);
|
||||
return;
|
||||
@@ -194,13 +195,22 @@ const FilePickerModal = ({ raw, open, cb, OnClose, onPick, _storage = '', _path
|
||||
))}
|
||||
</List>
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<Button href="/cosmos-ui/config-url" target="_blank" style={{width: '110px', textAlign:'center'}}>Add external storages</Button>
|
||||
<Button href="/cosmos-ui/storage" target="_blank" style={{width: '110px', textAlign:'center'}}>Add external storages</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>}
|
||||
<div style={{flexGrow: 1, minHeight: '300px'}}>
|
||||
{!explore && <div style={{padding: '5px 5px 5px 10px', margin: '0px 0px 5px 0px', background: 'rgba(0,0,0,0.1)'}}
|
||||
>{t('mgmt.storage.selected')}: {selectedFullPath}</div>}
|
||||
{!explore && <Stack style={{padding: '5px 5px 5px 10px', margin: '0px 0px 5px 0px', background: 'rgba(0,0,0,0.1)'}} direction="row" spacing={2} alignContent={'center'} alignItems={'center'}>
|
||||
<div>{canCreate ? <NewFolderButton cb={
|
||||
(res) => {
|
||||
updateFiles(selectedStorage, selectedPath, true);
|
||||
setTimeout(() => {
|
||||
setSelectedFullPath(res);
|
||||
}, 1000);
|
||||
}
|
||||
} storage={selectedStorage} path={selectedFullPath}/> : null}</div>
|
||||
<div>{t('mgmt.storage.selected')}: {selectedFullPath}</div>
|
||||
</Stack>}
|
||||
{convertToTreeView(directoriesAsTree)}
|
||||
</div>
|
||||
</Stack>
|
||||
@@ -231,15 +241,15 @@ const FilePickerModal = ({ raw, open, cb, OnClose, onPick, _storage = '', _path
|
||||
);
|
||||
};
|
||||
|
||||
const FilePickerButton = ({ raw, onPick, size = '100%', storage = '', path = '', select="any" }) => {
|
||||
const FilePickerButton = ({ disabled, raw, canCreate, onPick, size = '100%', storage = '', path = '', select="any" }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<IconButton onClick={() => setOpen(true)} disabled={disabled}>
|
||||
{onPick ? <><FolderAddFilled style={{fontSize: size}}/></> : <FolderViewOutlined style={{fontSize: size}}/>}
|
||||
</IconButton>
|
||||
{open && <FilePickerModal raw={raw} select={select} open={open} onPick={onPick} OnClose={() => setOpen(false)} _storage={storage} _path={path} />}
|
||||
{open && <FilePickerModal canCreate={canCreate} raw={raw} select={select} open={open} onPick={onPick} OnClose={() => setOpen(false)} _storage={storage} _path={path} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
77
client/src/components/newFileModal.jsx
Normal file
77
client/src/components/newFileModal.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// material-ui
|
||||
import * as React from 'react';
|
||||
import { Alert, Box, Button, Checkbox, CircularProgress, FormControl, FormHelperText, Grid, Icon, IconButton, InputLabel, List, ListItem, ListItemButton, MenuItem, NativeSelect, Select, Stack, TextField, Typography } from '@mui/material';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import {TreeItem, TreeView} from '@mui/x-tree-view';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { useFormik, FormikProvider } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderOpenOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, SafetyOutlined, UpOutlined, ExpandOutlined, FolderFilled, FileFilled, FileOutlined, LoadingOutlined, FolderAddFilled, FolderViewOutlined } from "@ant-design/icons";
|
||||
import * as API from '../api';
|
||||
import { current } from '@reduxjs/toolkit';
|
||||
import { json } from 'react-router';
|
||||
|
||||
const NewFolderModal = ({ raw, open, cb, OnClose, onPick, canCreate, storage = '', path = '/', select = 'any' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
onSubmit: (values) => {
|
||||
API.storage.newFolder(storage, path, values.name).then((res) => {
|
||||
OnClose();
|
||||
cb && cb(res.data.created);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={OnClose} fullWidth>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogTitle>{t('mgmt.storage.newFolderIn')} {storage}:{path}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
name='name'
|
||||
label={t('mgmt.storage.newFolderName')}
|
||||
type="text"
|
||||
fullWidth
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={OnClose}>{t('global.cancelAction')}</Button>
|
||||
<Button autoFocus type="submit">
|
||||
{t('global.confirmAction')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const NewFolderButton = ({ cb, storage = '', path = '/' }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="contained" onClick={() => setOpen(true)}>{t('mgmt.storage.newFolder')}</Button>
|
||||
{open && <NewFolderModal cb={cb} open={open} OnClose={() => setOpen(false)} storage={storage} path={path} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewFolderModal;
|
||||
export { NewFolderButton };
|
||||
@@ -49,7 +49,7 @@ const PrettyTabbedView = ({ rootURL, tabs, isLoading, currentTab, setCurrentTab
|
||||
useEffect(() => {
|
||||
if (!rootURL) return;
|
||||
|
||||
if (initialMount) {
|
||||
// if (initialMount) {
|
||||
// On initial mount, check if URL matches any tab
|
||||
const currentPath = decodeURIComponent(window.location.pathname).replace(/\/$/, '');
|
||||
const matchingTabIndex = tabs.findIndex(tab => {
|
||||
@@ -61,18 +61,27 @@ const PrettyTabbedView = ({ rootURL, tabs, isLoading, currentTab, setCurrentTab
|
||||
}
|
||||
|
||||
setInitialMount(false);
|
||||
} else {
|
||||
// }
|
||||
// else {
|
||||
// After initial mount, update URL when value changes
|
||||
window.history.pushState({}, '', `${rootURL}${tabs[value].url}`);
|
||||
}
|
||||
// window.history.pushState({}, '', `${rootURL}${tabs[value].url}`);
|
||||
// }
|
||||
}, [rootURL, tabs, value]);
|
||||
|
||||
const handleChange = (event, newValue) => {
|
||||
if (rootURL) {
|
||||
window.history.pushState({}, '', `${rootURL}${tabs[newValue].url}`);
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
setCurrentTab && setCurrentTab(newValue);
|
||||
};
|
||||
|
||||
const handleSelectChange = (event) => {
|
||||
if (rootURL) {
|
||||
window.history.pushState({}, '', `${rootURL}${tabs[event.target.value].url}`);
|
||||
}
|
||||
|
||||
setValue(event.target.value);
|
||||
setCurrentTab && setCurrentTab(event.target.value);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ import dayjs from 'dayjs';
|
||||
import MainCard from '../../../../components/MainCard';
|
||||
import Transitions from '../../../../components/@extended/Transitions';
|
||||
// assets
|
||||
import { BellOutlined, ClockCircleOutlined, CloseOutlined, CloseSquareOutlined, ExclamationCircleOutlined, GiftOutlined, InfoCircleOutlined, LoadingOutlined, MessageOutlined, PlaySquareOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { BellOutlined, ClockCircleOutlined, CloseOutlined, CloseSquareOutlined, ExclamationCircleOutlined, GiftOutlined, InfoCircleOutlined, LoadingOutlined, MessageOutlined, PlaySquareOutlined, ReloadOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
|
||||
import * as API from '../../../../api';
|
||||
import { redirectToLocal } from '../../../../utils/indexs';
|
||||
@@ -66,6 +66,8 @@ const Jobs = () => {
|
||||
const matchesXs = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [from, setFrom] = useState('');
|
||||
const anchorRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const statusColor = (job) => {
|
||||
return {
|
||||
@@ -76,8 +78,9 @@ const Jobs = () => {
|
||||
}[getStatus(job)]
|
||||
}
|
||||
|
||||
const refreshJobs = () => {
|
||||
const refreshJobs = (force) => {
|
||||
if (!isAdmin) return;
|
||||
if (!force && !open) return;
|
||||
|
||||
API.cron.list().then((res) => {
|
||||
setJobs(() => res.data);
|
||||
@@ -89,18 +92,20 @@ const Jobs = () => {
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshJobs();
|
||||
}, 20000);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const anchorRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
refreshJobs();
|
||||
refreshJobs(true);
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refreshJobs(true);
|
||||
};
|
||||
|
||||
const handleClose = (event) => {
|
||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||
@@ -117,10 +122,16 @@ const Jobs = () => {
|
||||
let flatList = [];
|
||||
jobs && Object.values(jobs).forEach((list) => list && Object.values(list).forEach(job => flatList.push(job)));
|
||||
flatList.sort((a, b) => {
|
||||
const getTimestamp = (date) => new Date(date).getTime() || 0;
|
||||
|
||||
if (a.Running && !b.Running) return -1;
|
||||
if (!a.Running && b.Running) return 1;
|
||||
if (a.Running && b.Running) return a.LastStarted - b.LastStarted;
|
||||
return a.LastRun - b.LastRun;
|
||||
|
||||
if (a.Running && b.Running) {
|
||||
return getTimestamp(a.LastStarted) - getTimestamp(b.LastStarted);
|
||||
}
|
||||
|
||||
return getTimestamp(b.LastRun) - getTimestamp(a.LastRun);
|
||||
});
|
||||
|
||||
if (!isAdmin) return null;
|
||||
@@ -179,9 +190,12 @@ const Jobs = () => {
|
||||
border={false}
|
||||
content={false}
|
||||
secondary={
|
||||
<><IconButton size="small" onClick={handleRefresh}>
|
||||
<ReloadOutlined />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={handleToggle}>
|
||||
<CloseOutlined />
|
||||
</IconButton>
|
||||
</IconButton></>
|
||||
}
|
||||
>
|
||||
<List
|
||||
|
||||
@@ -6,12 +6,15 @@ import * as API from "../../../../api";
|
||||
import React from "react";
|
||||
import RestartModal from "../../../../pages/config/users/restart";
|
||||
import { ConfirmModalDirect } from "../../../../components/confirmModal";
|
||||
import { useClientInfos } from "../../../../utils/hooks";
|
||||
|
||||
const RestartMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const [openResartModal, setOpenRestartModal] = React.useState(false);
|
||||
const [openRestartServerModal, setOpenRestartServerModal] = React.useState(false);
|
||||
const [status, setStatus] = React.useState({});
|
||||
const {role} = useClientInfos();
|
||||
const isAdmin = role === "2";
|
||||
|
||||
React.useEffect(() => {
|
||||
API.getStatus().then((res) => {
|
||||
@@ -21,7 +24,7 @@ const RestartMenu = () => {
|
||||
|
||||
const restartServer = API.restartServer;
|
||||
|
||||
return <>
|
||||
return isAdmin ? <>
|
||||
<RestartModal openModal={openResartModal} setOpenModal={setOpenRestartModal} />
|
||||
<RestartModal openModal={openRestartServerModal} setOpenModal={setOpenRestartModal} isHostMachine/>
|
||||
|
||||
@@ -33,7 +36,7 @@ const RestartMenu = () => {
|
||||
<ListItemText>{t('global.restartCosmos')}</ListItemText>
|
||||
</MenuItem>
|
||||
</MenuButton>
|
||||
</>
|
||||
</> : <></>;
|
||||
};
|
||||
|
||||
export default RestartMenu;
|
||||
@@ -32,6 +32,7 @@ const pages = {
|
||||
type: 'item',
|
||||
url: '/cosmos-ui/backups',
|
||||
icon: CloudServerOutlined,
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
|
||||
209
client/src/pages/backups/backupDialog.jsx
Normal file
209
client/src/pages/backups/backupDialog.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { Alert, Grid, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormHelperText, Stack, TextField } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
import { FormikProvider, useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import * as API from '../../api';
|
||||
import ResponsiveButton from "../../components/responseiveButton";
|
||||
import { PlusCircleOutlined } from "@ant-design/icons";
|
||||
import { crontabToText } from "../../utils/indexs";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { FilePickerButton } from '../../components/filePicker';
|
||||
import { CosmosInputText } from "../config/users/formShortcuts";
|
||||
|
||||
const isAbsolutePath = (path) => path.startsWith('/') || /^[a-zA-Z]:\\/.test(path); // Unix & Windows support
|
||||
|
||||
const BackupDialogInternal = ({ refresh, open, setOpen, data = {} }) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(data.Name);
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: data.Name || t('mgmt.backup.newBackup'),
|
||||
source: data.Source || '',
|
||||
repository: data.Repository || '/backups',
|
||||
crontab: data.Crontab || '0 0 4 * * *',
|
||||
crontabForget: data.CrontabForget || '0 0 12 * * *',
|
||||
retentionPolicy: data.RetentionPolicy || '--keep-last 3 --keep-daily 7 --keep-weekly 8 --keep-yearly 3'
|
||||
},
|
||||
validationSchema: yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.required(t('global.required'))
|
||||
.min(3, t('mgmt.backup.min3chars')),
|
||||
|
||||
source: yup
|
||||
.string()
|
||||
.required(t('global.required'))
|
||||
.test('is-absolute', t('global.absolutePath'), isAbsolutePath),
|
||||
|
||||
repository: yup
|
||||
.string()
|
||||
.required(t('global.required'))
|
||||
.test('is-absolute', t('global.absolutePath'), isAbsolutePath),
|
||||
|
||||
crontab: yup.string().required(t('global.required')),
|
||||
crontabForget: yup.string().required(t('global.required')),
|
||||
}),
|
||||
onSubmit: (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
console.log(values);
|
||||
setSubmitting(true);
|
||||
(data.Name ? API.backups.editBackup({
|
||||
...values
|
||||
}) : API.backups.addBackup({
|
||||
...values
|
||||
})).then(() => {
|
||||
setStatus({ success: true });
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
refresh && refresh();
|
||||
}).catch((err) => {
|
||||
setStatus({ success: false });
|
||||
setErrors({ submit: err.message });
|
||||
setSubmitting(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<FormikProvider value={formik}>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogTitle>
|
||||
{data.Name ? (t('global.edit') + " " + data.Name) : t('mgmt.backup.createBackup')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<Stack spacing={3} style={{ marginTop: '10px', width: '500px', maxWidth: '100%' }}>
|
||||
<Alert severity="info">
|
||||
<Trans i18nKey="mgmt.backup.createBackupInfo" />
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
name="name"
|
||||
disabled={isEdit}
|
||||
label={t('global.name')}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="flex-end">
|
||||
{!isEdit && <FilePickerButton
|
||||
onPick={(path) => {
|
||||
if(path)
|
||||
formik.setFieldValue('source', path);
|
||||
}}
|
||||
size="150%"
|
||||
select="folder"
|
||||
/>}
|
||||
<CosmosInputText
|
||||
name="source"
|
||||
disabled={isEdit}
|
||||
label={t('mgmt.backup.source')}
|
||||
placeholder="/folder/to/backup"
|
||||
formik={formik}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="flex-end">
|
||||
{!isEdit && <FilePickerButton
|
||||
onPick={(path) => {
|
||||
if(path)
|
||||
formik.setFieldValue('repository', path);
|
||||
}}
|
||||
canCreate={true}
|
||||
size="150%"
|
||||
select="folder"
|
||||
/>}
|
||||
<CosmosInputText
|
||||
name="repository"
|
||||
disabled={isEdit}
|
||||
label={t('mgmt.backup.repository')}
|
||||
placeholder="/where/to/store/backups"
|
||||
formik={formik}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="crontab"
|
||||
label={t('mgmt.backup.schedule')}
|
||||
value={formik.values.crontab}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.crontab && Boolean(formik.errors.crontab)}
|
||||
helperText={formik.touched.crontab && formik.errors.crontab || crontabToText(formik.values.crontab, t)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="crontabForget"
|
||||
label={t('mgmt.backup.scheduleForget')}
|
||||
value={formik.values.crontabForget}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.crontabForget && Boolean(formik.errors.crontabForget)}
|
||||
helperText={formik.touched.crontabForget && formik.errors.crontabForget || crontabToText(formik.values.crontabForget, t)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="retentionPolicy"
|
||||
label={t('mgmt.backup.rententionPolicy')}
|
||||
value={formik.values.retentionPolicy}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.retentionPolicy && Boolean(formik.errors.retentionPolicy)}
|
||||
helperText={formik.touched.retentionPolicy && formik.errors.retentionPolicy}
|
||||
/>
|
||||
|
||||
{formik.errors.submit && (
|
||||
<Grid item xs={12}>
|
||||
<FormHelperText error>{formik.errors.submit}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>{t('global.cancelAction')}</Button>
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
type="submit"
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
{data.Name ? t('global.update') : t('global.createAction')}
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</FormikProvider>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const BackupDialog = ({ refresh, data }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <BackupDialogInternal refresh={refresh} open={open} setOpen={setOpen} data={data} />}
|
||||
<div>
|
||||
{!data ? (
|
||||
<ResponsiveButton
|
||||
onClick={() => setOpen(true)}
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PlusCircleOutlined />}
|
||||
>
|
||||
{t('mgmt.backup.newBackup')}
|
||||
</ResponsiveButton>
|
||||
) : (
|
||||
<div onClick={() => setOpen(true)}>{t('global.edit')}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupDialog;
|
||||
export { BackupDialog, BackupDialogInternal };
|
||||
147
client/src/pages/backups/backups.jsx
Normal file
147
client/src/pages/backups/backups.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import * as API from "../../api";
|
||||
import PrettyTableView from "../../components/tableView/prettyTableView";
|
||||
import { CloudOutlined, CloudServerOutlined, DeleteOutlined, EditOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Checkbox, CircularProgress, ListItemIcon, ListItemText, MenuItem, Stack } from "@mui/material";
|
||||
import { crontabToText } from "../../utils/indexs";
|
||||
import MenuButton from "../../components/MenuButton";
|
||||
import ResponsiveButton from "../../components/responseiveButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModalDirect } from "../../components/confirmModal";
|
||||
import BackupDialog, { BackupDialogInternal } from "./backupDialog";
|
||||
|
||||
export const Backups = () => {
|
||||
const { t } = useTranslation();
|
||||
// const [isAdmin, setIsAdmin] = useState(false);
|
||||
let isAdmin = true;
|
||||
const [config, setConfig] = useState(null);
|
||||
const [backups, setBackups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteBackup, setDeleteBackup] = useState(null);
|
||||
const [editOpened, setEditOpened] = useState(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
let configAsync = await API.config.get();
|
||||
setConfig(configAsync.data);
|
||||
setBackups(configAsync.data.Backup.Backups || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const apiDeleteBackup = async (name) => {
|
||||
setLoading(true);
|
||||
await API.backups.removeBackup(name, true);
|
||||
setLoading(false);
|
||||
setDeleteBackup(null);
|
||||
refresh();
|
||||
}
|
||||
|
||||
const tryDeleteBackup = async (name) => {
|
||||
setDeleteBackup(name);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
{(config) ? <>
|
||||
{deleteBackup && <ConfirmModalDirect
|
||||
title="Delete Backup"
|
||||
content={t('mgmt.backup.confirmBackupDeletion')}
|
||||
callback={() => apiDeleteBackup(deleteBackup)}
|
||||
onClose={() => setDeleteBackup(null)}
|
||||
/>}
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-start">
|
||||
<BackupDialog refresh={refresh}/>
|
||||
<ResponsiveButton variant="outlined" startIcon={<ReloadOutlined />} onClick={refresh}>
|
||||
{t('global.refresh')}
|
||||
</ResponsiveButton>
|
||||
</Stack>
|
||||
<div>
|
||||
{editOpened && <BackupDialogInternal
|
||||
refresh={refresh}
|
||||
open={editOpened}
|
||||
setOpen={setEditOpened}
|
||||
data={editOpened}
|
||||
/>}
|
||||
{backups && <PrettyTableView
|
||||
linkTo={r => '/cosmos-ui/backups/' + r.Name}
|
||||
data={Object.values(backups)}
|
||||
getKey={(r) => r.Name}
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
field: () => <CloudServerOutlined />,
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
width: '64px',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('global.name'),
|
||||
field: (r) => r.Name,
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.sourceTitle'),
|
||||
field: (r) => r.Source,
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repositoryTitle'),
|
||||
field: (r) => r.Repository,
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.scheduleTitle'),
|
||||
screenMin: 'sm',
|
||||
field: (r) => crontabToText(r.Crontab, t),
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
clickable:true,
|
||||
field: (r) => {
|
||||
return <div style={{position: 'relative'}}>
|
||||
<MenuButton>
|
||||
<MenuItem disabled={loading} onClick={() => {
|
||||
window.open('/cosmos-ui/backups/' + r.Name, '_blank');
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<EditOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('global.open')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled={loading} onClick={() => setEditOpened(r)}>
|
||||
<ListItemIcon>
|
||||
<EditOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('global.edit')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled={loading} onClick={() => tryDeleteBackup(r.name)}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('global.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</MenuButton>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>}
|
||||
{!backups &&
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Stack>
|
||||
</> : <center>
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
</center>}
|
||||
</>;
|
||||
};
|
||||
249
client/src/pages/backups/fileExplorer.jsx
Normal file
249
client/src/pages/backups/fileExplorer.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// material-ui
|
||||
import * as React from 'react';
|
||||
import { Alert, Box, Button, Checkbox, CircularProgress, FormControl, FormHelperText, Grid, Icon, IconButton, InputLabel, List, ListItem, ListItemButton, MenuItem, NativeSelect, Select, Stack, TextField, Typography } from '@mui/material';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import {TreeItem, TreeView} from '@mui/x-tree-view';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { useFormik, FormikProvider } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderOpenOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, SafetyOutlined, UpOutlined, ExpandOutlined, FolderFilled, FileFilled, FileOutlined, LoadingOutlined, FolderAddFilled, FolderViewOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import * as API from '../../api';
|
||||
import {simplifyNumber} from '../dashboard/components/utils';
|
||||
import { current } from '@reduxjs/toolkit';
|
||||
import { json } from 'react-router';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import RestoreDialog from './restoreDialog';
|
||||
|
||||
function pathIncluded(list, path, originalSource) {
|
||||
if(list.includes(path) || list.includes(originalSource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pathParts = path.split('/');
|
||||
return list.some(p => {
|
||||
const parentParts = p.split('/');
|
||||
return parentParts.every((part, i) => pathParts[i] === part);
|
||||
});
|
||||
}
|
||||
|
||||
function metaData(file) {
|
||||
// get latest change between mtime and ctime and return with dayjs time ago
|
||||
const mtime = file.mtime;
|
||||
const ctime = file.ctime;
|
||||
|
||||
let latest;
|
||||
|
||||
let res = "";
|
||||
|
||||
if (file.size) {
|
||||
res += simplifyNumber(file.size, 'B');
|
||||
|
||||
if(mtime && ctime) {
|
||||
res += " | ";
|
||||
}
|
||||
}
|
||||
|
||||
if(mtime && ctime) {
|
||||
if (mtime > ctime) {
|
||||
latest = mtime;
|
||||
} else {
|
||||
latest = ctime;
|
||||
}
|
||||
|
||||
res += new Date(latest).toLocaleString();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
const BackupFileExplorer = ({ getFile, onSelect, selectedSnapshot, backupName, backup, className = '' }) => {
|
||||
const [files, setFiles] = React.useState({});
|
||||
const [directoriesAsTree, setDirectoriesAsTree] = React.useState({});
|
||||
const [selectedPath, setSelectedPath] = React.useState('');
|
||||
const [candidatePaths, setCandidatePaths] = React.useState([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
function transformToTree(data) {
|
||||
const root = {
|
||||
path: Object.keys(data)[0],
|
||||
label: Object.keys(data)[0].split('/').pop(),
|
||||
opened: true,
|
||||
file: {
|
||||
type: "dir",
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
root.label = backup.Source
|
||||
|
||||
function buildTree(node) {
|
||||
const path = node.path;
|
||||
if (data[path]) {
|
||||
for (const item of data[path]) {
|
||||
const childPath = `${path}/${item.name}`.replace(/^\/\//, '/');
|
||||
const child = {
|
||||
path: childPath,
|
||||
label: item.name,
|
||||
file: item,
|
||||
};
|
||||
|
||||
if (data[childPath]) {
|
||||
child.children = [];
|
||||
buildTree(child);
|
||||
}
|
||||
|
||||
node.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTree(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
updateFiles(backup.Source, true);
|
||||
}, [selectedSnapshot]);
|
||||
|
||||
const updateFiles = async (path, forceNew) => {
|
||||
// If already loaded, just update the selection
|
||||
if (!forceNew && files[path]) {
|
||||
setSelectedPath(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if(forceNew) {
|
||||
setCandidatePaths([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getFile(path);
|
||||
if (!result.data) return;
|
||||
|
||||
const metadata = result.data.shift();
|
||||
const files = result.data.filter(i => i.path != path);
|
||||
|
||||
setFiles(current => {
|
||||
let newFiles = {
|
||||
...current,
|
||||
[path]: files || []
|
||||
};
|
||||
if(forceNew) {
|
||||
newFiles = {
|
||||
[path]: files || []
|
||||
};
|
||||
}
|
||||
setDirectoriesAsTree(transformToTree(newFiles));
|
||||
return newFiles;
|
||||
});
|
||||
setSelectedPath(path);
|
||||
} catch (error) {
|
||||
console.error('Error loading files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleNode = (event, nodePath) => {
|
||||
nodePath = nodePath.split('@').pop();
|
||||
console.log(nodePath)
|
||||
if (nodePath === backup.Source) {
|
||||
updateFiles(nodePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the parent path and filename
|
||||
let nodeParent = nodePath.split('/').slice(0, -1).join('/');
|
||||
nodeParent = nodeParent.replace(/\/$/, '');
|
||||
if (nodeParent === '') {
|
||||
nodeParent = '/';
|
||||
}
|
||||
const filename = nodePath.split('/').pop();
|
||||
|
||||
if (files[nodeParent]) {
|
||||
const file = files[nodeParent].find(f => f.name === filename);
|
||||
if (file) {
|
||||
if (file.type == "dir") {
|
||||
updateFiles(nodePath);
|
||||
}
|
||||
onSelect && onSelect(nodePath);
|
||||
setSelectedPath(nodePath);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderAllTree = (node) => {
|
||||
let nodeCounter = 0;
|
||||
|
||||
const renderTree = (node) => {
|
||||
nodeCounter++;
|
||||
return (
|
||||
<TreeItem
|
||||
key={node.path}
|
||||
nodeId={selectedSnapshot + "@" + node.path}
|
||||
label={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
{nodeCounter > 1 ? <Checkbox style={{zIndex: 1000}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if(candidatePaths.some(path => node.path == path)) {
|
||||
setCandidatePaths(candidatePaths.filter(path => node.path != path));
|
||||
} else {
|
||||
setCandidatePaths([...candidatePaths, node.path]);
|
||||
}
|
||||
}}
|
||||
checked={pathIncluded(candidatePaths, node.path)}
|
||||
disabled={pathIncluded(candidatePaths, node.path) && !candidatePaths.some(path => node.path == path)}
|
||||
/> : <Checkbox style={{zIndex: 1000, opacity: 0, width: '1px', paddingLeft:0, paddingRight:0, margin: 0}} disabled />}
|
||||
{node.label}
|
||||
</Stack>
|
||||
<div style={{opacity: 0.8}}>{metaData(node.file)}</div>
|
||||
</div>
|
||||
}
|
||||
icon={node.file && node.file.type == "dir" ? null : <FileOutlined className="h-4 w-4" />}
|
||||
>
|
||||
{Array.isArray(node.children)
|
||||
? node.children.map((child) => renderTree(child))
|
||||
: (
|
||||
(node.file && node.file.type == "dir") ?
|
||||
<TreeItem key={node.id + "load"} nodeId={(++nodeCounter).toString()} label={"Loading..."} icon={<LoadingOutlined />} />
|
||||
: null
|
||||
)}
|
||||
</TreeItem>
|
||||
);
|
||||
};
|
||||
|
||||
return renderTree(node);
|
||||
}
|
||||
|
||||
if(Object.keys(directoriesAsTree).length === 0) {
|
||||
return (
|
||||
<Stack spacing={2} justifyContent={'center'} alignItems={'center'}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className={className}>
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-start">
|
||||
<RestoreDialog originalSource={backup.Source} candidatePaths={candidatePaths} selectedSnapshot={selectedSnapshot} backupName={backupName} />
|
||||
</Stack>
|
||||
<TreeView
|
||||
onNodeSelect={handleToggleNode}
|
||||
defaultCollapseIcon={<FolderOpenOutlined className="h-4 w-4" />}
|
||||
defaultExpandIcon={<FolderFilled className="h-4 w-4" />}
|
||||
>
|
||||
{Object.keys(directoriesAsTree).length > 0 && renderAllTree(directoriesAsTree)}
|
||||
</TreeView>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupFileExplorer;
|
||||
36
client/src/pages/backups/index.jsx
Normal file
36
client/src/pages/backups/index.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Stack, CircularProgress, Card, Typography, useMediaQuery, Chip, Button } from '@mui/material';
|
||||
import { CloudServerOutlined, CalendarOutlined, FolderOutlined, ReloadOutlined, InfoCircleOutlined, CloudOutlined } from '@ant-design/icons';
|
||||
import * as API from '../../api';
|
||||
import { crontabToText } from '../../utils/indexs';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import MainCard from '../../components/MainCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BackupOverview from './overview';
|
||||
import PrettyTabbedView from '../../components/tabbedView/tabbedView';
|
||||
import Back from '../../components/back';
|
||||
import BackupRestore from './restore';
|
||||
import EventExplorerStandalone from '../dashboard/eventsExplorerStandalone';
|
||||
import { Backups } from './backups';
|
||||
import { Repositories } from './repositories';
|
||||
|
||||
export default function AllBackupsIndex() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div>
|
||||
<Stack spacing={1}>
|
||||
<PrettyTabbedView
|
||||
tabs={[
|
||||
{
|
||||
title: t('mgmt.backup.backups'),
|
||||
children: <Backups />
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repositories'),
|
||||
children: <Repositories />
|
||||
},
|
||||
]} />
|
||||
</Stack>
|
||||
</div>;
|
||||
}
|
||||
186
client/src/pages/backups/overview.jsx
Normal file
186
client/src/pages/backups/overview.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Stack, CircularProgress, Card, Typography, useMediaQuery, Chip, Button } from '@mui/material';
|
||||
import { CloudServerOutlined, CalendarOutlined, FolderOutlined, ReloadOutlined, InfoCircleOutlined, CloudOutlined, UpCircleOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import * as API from '../../api';
|
||||
import { crontabToText } from '../../utils/indexs';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import MainCard from '../../components/MainCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function BackupOverview({backupName}) {
|
||||
const [backup, setBackup] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [isNotFound, setIsNotFound] = useState(false);
|
||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
|
||||
const { t } = useTranslation();
|
||||
const [taskQueued, setTaskQueued] = useState(0);
|
||||
|
||||
const infoStyle = {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px',
|
||||
borderRadius: '5px',
|
||||
}
|
||||
|
||||
const getChip = (backup, snapshots) => {
|
||||
if (snapshots.length == 0) {
|
||||
return <Chip label="No Snapshots" color="warning" />;
|
||||
}
|
||||
else if ((new Date(snapshots[0].time).getTime()) < Date.now() - 1000 * 60 * 60 * 24 * 60) {
|
||||
return <Chip label="Old" color="warning" />;
|
||||
}
|
||||
else {
|
||||
return <Chip label="Active" color="success" />;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBackupDetails = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const configResponse = await API.config.get();
|
||||
const backupData = configResponse.data.Backup.Backups[backupName];
|
||||
|
||||
if (!backupData) {
|
||||
setIsNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBackup(backupData);
|
||||
|
||||
// Fetch snapshots for this backup
|
||||
const snapshotsResponse = await API.backups.listSnapshots(backupName);
|
||||
if(snapshotsResponse.data && snapshotsResponse.data.reverse) {
|
||||
snapshotsResponse.data.reverse();
|
||||
}
|
||||
setSnapshots(snapshotsResponse.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching backup details:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackupDetails();
|
||||
}, [backupName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack spacing={3} alignContent={'center'} justifyContent={'center'} alignItems={'center'}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backup) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Typography variant="h6" className="text-red-500">
|
||||
Backup not found: {backupName}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function backupNow() {
|
||||
API.backups.backupNow(backupName).then(() => {
|
||||
setTaskQueued((new Date()).getTime() + 1500);
|
||||
setTimeout(() => {
|
||||
setTaskQueued(0);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function forgetNow() {
|
||||
API.backups.forgetNow(backupName).then(() => {
|
||||
setTaskQueued((new Date()).getTime() + 1500);
|
||||
setTimeout(() => {
|
||||
setTaskQueued(0);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} className="p-4" style={{ maxWidth: '1000px' }}>
|
||||
<MainCard name={backup.Name} title={<div>{backup.Name}</div>}>
|
||||
<Stack spacing={2} direction={isMobile ? 'column' : 'row'} alignItems={isMobile ? 'center' : 'flex-start'}>
|
||||
{/* Left Column */}
|
||||
<Stack spacing={2} direction="column" justifyContent="center" alignItems="center">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloudServerOutlined style={{ fontSize: '128px' }} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>{getChip(backup, snapshots)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
Latest Backup: {snapshots.length > 0 ? new Date(snapshots[0].time).toLocaleDateString() : 'N/A'}
|
||||
</strong>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack spacing={2} style={{ width: '100%' }}>
|
||||
{taskQueued < (new Date()).getTime() ?
|
||||
<Stack spacing={2} direction="row">
|
||||
<ResponsiveButton
|
||||
variant="outlined"
|
||||
startIcon={<ReloadOutlined />}
|
||||
onClick={fetchBackupDetails}
|
||||
>
|
||||
{t('global.refresh')}
|
||||
</ResponsiveButton>
|
||||
<ResponsiveButton
|
||||
variant="outlined"
|
||||
startIcon={<UpCircleOutlined />}
|
||||
onClick={backupNow}
|
||||
>
|
||||
Backup Now
|
||||
</ResponsiveButton>
|
||||
<ResponsiveButton
|
||||
variant="outlined"
|
||||
startIcon={<DeleteOutlined />}
|
||||
onClick={forgetNow}
|
||||
>
|
||||
Clean up Now
|
||||
</ResponsiveButton>
|
||||
</Stack> : <Stack style={{fontSize: '120%'}}>Task has been queued succesfuly</Stack>}
|
||||
|
||||
<strong><FolderOutlined /> Source</strong>
|
||||
<div style={infoStyle}>{backup.Source}</div>
|
||||
|
||||
<strong><InfoCircleOutlined /> Repository</strong>
|
||||
<div style={infoStyle}>{backup.Repository}</div>
|
||||
|
||||
<strong><CalendarOutlined /> Backup Schedule</strong>
|
||||
<div style={infoStyle}>{crontabToText(backup.Crontab)}</div>
|
||||
|
||||
<strong><CalendarOutlined /> Clean up Schedule</strong>
|
||||
<div style={infoStyle}>{crontabToText(backup.CrontabForget)}</div>
|
||||
|
||||
|
||||
<strong><CloudServerOutlined /> Latest Snapshots</strong>
|
||||
<div>
|
||||
{snapshots.slice(0, 5).map((snapshot, index) => (
|
||||
<Stack key={index} direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Chip
|
||||
key={index}
|
||||
label={<><strong>{new Date(snapshot.time).toLocaleString()} </strong>{isMobile ? '' : ` - (${snapshot.short_id || 'Unknown'})`}</>}
|
||||
style={{ margin: '5px', width: '100%' }}
|
||||
/>
|
||||
<Link to={`/cosmos-ui/backups/${backupName}/restore?s=${snapshot.id}`}>
|
||||
<Button variant="contained">Open</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
))}
|
||||
{snapshots.length === 0 && (
|
||||
<div style={infoStyle}>No snapshots available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
89
client/src/pages/backups/repositories.jsx
Normal file
89
client/src/pages/backups/repositories.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import * as API from "../../api";
|
||||
import PrettyTableView from "../../components/tableView/prettyTableView";
|
||||
import { CloudOutlined, CloudServerOutlined, DeleteOutlined, EditOutlined, FolderOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Checkbox, CircularProgress, ListItemIcon, ListItemText, MenuItem, Stack } from "@mui/material";
|
||||
import { crontabToText } from "../../utils/indexs";
|
||||
import MenuButton from "../../components/MenuButton";
|
||||
import ResponsiveButton from "../../components/responseiveButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModalDirect } from "../../components/confirmModal";
|
||||
import BackupDialog, { BackupDialogInternal } from "./backupDialog";
|
||||
import {simplifyNumber} from '../dashboard/components/utils';
|
||||
|
||||
export const Repositories = () => {
|
||||
const { t } = useTranslation();
|
||||
// const [isAdmin, setIsAdmin] = useState(false);
|
||||
let isAdmin = true;
|
||||
const [repositories, setRepositories] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteBackup, setDeleteBackup] = useState(null);
|
||||
const [editOpened, setEditOpened] = useState(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
let configAsync = await API.backups.listRepo();
|
||||
setRepositories(configAsync.data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} justifyContent="center" alignContent={"center"} alignItems={"center"}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return <>
|
||||
{(repositories) ? <>
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-start">
|
||||
<ResponsiveButton variant="outlined" startIcon={<ReloadOutlined />} onClick={refresh}>
|
||||
{t('global.refresh')}
|
||||
</ResponsiveButton>
|
||||
</Stack>
|
||||
<PrettyTableView
|
||||
linkTo={r => '/cosmos-ui/backups/repo/' + r.id}
|
||||
data={Object.values(repositories)}
|
||||
getKey={(r) => r.Name}
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
field: () => <FolderOutlined />,
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
width: '64px',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repository'),
|
||||
field: (r) => r.path,
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repository.size'),
|
||||
field: (r) => simplifyNumber(r.stats.total_uncompressed_size, "B") + " (" + simplifyNumber(r.stats.total_size, "B") + " on disk)",
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repository.total_file_count'),
|
||||
field: (r) => simplifyNumber(r.stats.total_file_count, ""),
|
||||
underline: true,
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.repository.snapshots_count'),
|
||||
field: (r) => r.stats.snapshots_count,
|
||||
underline: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</> : <center>
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
</center>}
|
||||
</>;
|
||||
};
|
||||
101
client/src/pages/backups/restore.jsx
Normal file
101
client/src/pages/backups/restore.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Stack, CircularProgress, Card, Typography, useMediaQuery, Chip, Button, Select, MenuItem } from '@mui/material';
|
||||
import { CloudServerOutlined, CalendarOutlined, FolderOutlined, ReloadOutlined, InfoCircleOutlined, CloudOutlined } from '@ant-design/icons';
|
||||
import * as API from '../../api';
|
||||
import { crontabToText } from '../../utils/indexs';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import MainCard from '../../components/MainCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileExplorer from './fileExplorer';
|
||||
import BackupFileExplorer from './fileExplorer';
|
||||
|
||||
export default function BackupRestore({backupName}) {
|
||||
const [sp] = useSearchParams();
|
||||
|
||||
const [backup, setBackup] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState(sp.get('s'));
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isNotFound, setIsNotFound] = useState(false);
|
||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getSubfolderRestoreSize = () => {
|
||||
API.backups.subfolderRestoreSize(backupName, selectedSnapshot, '/').then((response) => {
|
||||
console.log(response)
|
||||
});
|
||||
}
|
||||
|
||||
const fetchBackupDetails = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const configResponse = await API.config.get();
|
||||
const backupData = configResponse.data.Backup.Backups[backupName];
|
||||
|
||||
if (!backupData) {
|
||||
setIsNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBackup(backupData);
|
||||
|
||||
// Fetch snapshots for this backup
|
||||
const snapshotsResponse = await API.backups.listSnapshots(backupName);
|
||||
if(snapshotsResponse.data && snapshotsResponse.data.reverse) {
|
||||
snapshotsResponse.data.reverse();
|
||||
}
|
||||
|
||||
setSnapshots(snapshotsResponse.data || []);
|
||||
|
||||
if(!selectedSnapshot && snapshotsResponse.data.length > 0) {
|
||||
setSelectedSnapshot(snapshotsResponse.data[0].id);
|
||||
}
|
||||
|
||||
// getSubfolderRestoreSize();
|
||||
} catch (error) {
|
||||
console.error('Error fetching backup details:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackupDetails();
|
||||
}, [backupName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack spacing={2} justifyContent={'center'} alignItems={'center'}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backup) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Typography variant="h6">
|
||||
Backup not found: {backupName}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} style={{ maxWidth: '1000px' }}>
|
||||
<div>
|
||||
<Select value={selectedSnapshot} onChange={(event) => setSelectedSnapshot(event.target.value)} sx={{ minWidth: 120, marginBottom: '15px' }}>
|
||||
{snapshots && snapshots.map((snap, index) => (
|
||||
<MenuItem key={snap.id} value={snap.id}>
|
||||
{new Date(snap.time).toLocaleString()} - ({snap.short_id})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<MainCard name={"Files in Snapshot"} title={<div>{"Files in Snapshot"}</div>}>
|
||||
{selectedSnapshot && <BackupFileExplorer backup={backup} backupName={backupName} selectedSnapshot={selectedSnapshot} getFile={(path) => API.backups.listFolders(backupName, selectedSnapshot, path)} />}
|
||||
</MainCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
127
client/src/pages/backups/restoreDialog.jsx
Normal file
127
client/src/pages/backups/restoreDialog.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { Alert, Grid, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormHelperText, Stack, TextField } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
import { FormikProvider, useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import * as API from '../../api';
|
||||
import ResponsiveButton from "../../components/responseiveButton";
|
||||
import { PlusCircleOutlined, UpOutlined } from "@ant-design/icons";
|
||||
import { crontabToText } from "../../utils/indexs";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { FilePickerButton } from '../../components/filePicker';
|
||||
import { CosmosInputText } from "../config/users/formShortcuts";
|
||||
|
||||
const isAbsolutePath = (path) => path.startsWith('/') || /^[a-zA-Z]:\\/.test(path); // Unix & Windows support
|
||||
|
||||
const RestoreDialogInternal = ({ refresh, open, setOpen, candidatePaths, originalSource, selectedSnapshot, backupName }) => {
|
||||
const { t } = useTranslation();
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
target: originalSource || '',
|
||||
},
|
||||
validationSchema: yup.object({
|
||||
target: yup
|
||||
.string()
|
||||
.required(t('global.required'))
|
||||
.test('is-absolute', t('global.absolutePath'), isAbsolutePath),
|
||||
}),
|
||||
onSubmit: async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
console.log(values);
|
||||
setSubmitting(true);
|
||||
|
||||
await API.backups.restoreBackup(backupName, {
|
||||
snapshotID: selectedSnapshot,
|
||||
target: values.target,
|
||||
include: candidatePaths,
|
||||
});
|
||||
|
||||
setDone(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<FormikProvider value={formik}>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogTitle>
|
||||
Restore Snapshot
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{!done ? <Stack spacing={3} style={{ marginTop: '10px', width: '500px', maxWidth: '100%' }}>
|
||||
<Alert severity="info">
|
||||
You are about to restore {candidatePaths.length ? candidatePaths.length : "every"} file(s)/folder(s). This will overwrite existing files.
|
||||
</Alert>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="flex-end">
|
||||
<FilePickerButton
|
||||
canCreate
|
||||
onPick={(path) => {
|
||||
if(path)
|
||||
formik.setFieldValue('target', path);
|
||||
}}
|
||||
size="150%"
|
||||
select="folder"
|
||||
/>
|
||||
<CosmosInputText
|
||||
name="target"
|
||||
label={"Where do you want to restore to?"}
|
||||
placeholder="/path/to/restore"
|
||||
formik={formik}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{formik.errors.submit && (
|
||||
<Grid item xs={12}>
|
||||
<FormHelperText error>{formik.errors.submit}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack> : <Stack>
|
||||
<Alert severity="success">
|
||||
Restore operation has been queued. You can monitor the progress in the <strong>Tasks</strong> log on the top right.
|
||||
</Alert>
|
||||
</Stack>}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>{t('global.cancelAction')}</Button>
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
type="submit"
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
Restore
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</FormikProvider>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const RestoreDialog = ({ refresh, candidatePaths, originalSource, selectedSnapshot, backupName }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <RestoreDialogInternal refresh={refresh} open={open} setOpen={setOpen} originalSource={originalSource} candidatePaths={candidatePaths} selectedSnapshot={selectedSnapshot} backupName={backupName} />}
|
||||
<div>
|
||||
<ResponsiveButton
|
||||
onClick={() => setOpen(true)}
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<UpOutlined />}
|
||||
>
|
||||
{candidatePaths.length === 0 ? 'Restore All' : 'Restore Selected'}
|
||||
</ResponsiveButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestoreDialog;
|
||||
export { RestoreDialog, RestoreDialogInternal };
|
||||
54
client/src/pages/backups/single-backup-index.jsx
Normal file
54
client/src/pages/backups/single-backup-index.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Stack, CircularProgress, Card, Typography, useMediaQuery, Chip, Button } from '@mui/material';
|
||||
import { CloudServerOutlined, CalendarOutlined, FolderOutlined, ReloadOutlined, InfoCircleOutlined, CloudOutlined } from '@ant-design/icons';
|
||||
import * as API from '../../api';
|
||||
import { crontabToText } from '../../utils/indexs';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import MainCard from '../../components/MainCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BackupOverview from './overview';
|
||||
import PrettyTabbedView from '../../components/tabbedView/tabbedView';
|
||||
import Back from '../../components/back';
|
||||
import BackupRestore from './restore';
|
||||
import EventExplorerStandalone from '../dashboard/eventsExplorerStandalone';
|
||||
import SingleRepoIndex from './single-repo-index';
|
||||
|
||||
export default function SingleBackupIndex() {
|
||||
const { t } = useTranslation();
|
||||
const { backupName } = useParams();
|
||||
|
||||
return <div>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Back />
|
||||
<div>{backupName}</div>
|
||||
</Stack>
|
||||
|
||||
<PrettyTabbedView
|
||||
rootURL={`/cosmos-ui/backups/${backupName}`}
|
||||
tabs={[
|
||||
{
|
||||
title: t('mgmt.servapps.overview'),
|
||||
url: '/',
|
||||
children: <BackupOverview backupName={backupName} />
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.restore'),
|
||||
url: '/restore',
|
||||
children: <BackupRestore backupName={backupName} />
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.snapshots'),
|
||||
url: '/snapshots',
|
||||
children: <SingleRepoIndex singleBackup/>
|
||||
},
|
||||
{
|
||||
title: t('navigation.monitoring.eventsTitle'),
|
||||
url: '/events',
|
||||
children: <EventExplorerStandalone initSearch={`{"object":"backup@${backupName}"}`}/>
|
||||
},
|
||||
]} />
|
||||
</Stack>
|
||||
</div>;
|
||||
}
|
||||
124
client/src/pages/backups/single-repo-index.jsx
Normal file
124
client/src/pages/backups/single-repo-index.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Stack, CircularProgress, Card, Typography, useMediaQuery, Chip, Button } from '@mui/material';
|
||||
import { CloudServerOutlined, CalendarOutlined, FolderOutlined, ReloadOutlined, InfoCircleOutlined, CloudOutlined } from '@ant-design/icons';
|
||||
import * as API from '../../api';
|
||||
import { crontabToText } from '../../utils/indexs';
|
||||
import ResponsiveButton from '../../components/responseiveButton';
|
||||
import MainCard from '../../components/MainCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PrettyTableView from '../../components/tableView/prettyTableView';
|
||||
import { simplifyNumber } from '../dashboard/components/utils';
|
||||
import { DeleteButton } from "../../components/delete";
|
||||
|
||||
export default function SingleRepoIndex({singleBackup}) {
|
||||
const { backupName } = useParams();
|
||||
const [backup, setBackup] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [isNotFound, setIsNotFound] = useState(false);
|
||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const infoStyle = {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px',
|
||||
borderRadius: '5px',
|
||||
}
|
||||
|
||||
const fetchBackupDetails = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const configResponse = await API.config.get();
|
||||
const backupData = configResponse.data.Backup.Backups[backupName];
|
||||
|
||||
if (!backupData) {
|
||||
setIsNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBackup(backupData);
|
||||
|
||||
// Fetch snapshots for this backup
|
||||
const snapshotsResponse = singleBackup ?
|
||||
await API.backups.listSnapshots(backupName) :
|
||||
await API.backups.listSnapshotsFromRepo(backupName)
|
||||
|
||||
if(snapshotsResponse.data && snapshotsResponse.data.reverse) {
|
||||
snapshotsResponse.data.reverse();
|
||||
}
|
||||
setSnapshots(snapshotsResponse.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching backup details:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackupDetails();
|
||||
}, [backupName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack spacing={3} alignContent={'center'} justifyContent={'center'} alignItems={'center'}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backup) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Typography variant="h6" className="text-red-500">
|
||||
No repository found for: {backupName}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} className="p-4" style={{ maxWidth: '1000px' }}>
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-start">
|
||||
<ResponsiveButton variant="outlined" startIcon={<ReloadOutlined />} onClick={fetchBackupDetails}>
|
||||
{t('global.refresh')}
|
||||
</ResponsiveButton>
|
||||
</Stack>
|
||||
<PrettyTableView
|
||||
data={Object.values(snapshots)}
|
||||
getKey={(r) => r.Name}
|
||||
columns={[
|
||||
{
|
||||
title: t('mgmt.backup.id'),
|
||||
field: (r) => r.short_id + ' (' + r.program_version + ')',
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.backup'),
|
||||
field: (r) => r.tags.join(', '),
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.date'),
|
||||
field: (r) => (new Date(r.time)).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.data'),
|
||||
field: (r) => simplifyNumber(r.summary.total_bytes_processed, "B") + ' (' + simplifyNumber(r.summary.data_added, "B") + ' added)',
|
||||
},
|
||||
{
|
||||
title: t('mgmt.backup.files'),
|
||||
field: (r) => simplifyNumber(r.summary.total_files_processed, "") + ' (' + simplifyNumber(r.summary.files_new, "") + ' added)',
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
field: (r) => {
|
||||
return <Stack direction="row" spacing={1}>
|
||||
<DeleteButton onDelete={async () => {
|
||||
await API.backups.forgetSnapshot(backupName, r.id);
|
||||
}}></DeleteButton>
|
||||
</Stack>
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -149,6 +149,7 @@ const ConfigManagement = () => {
|
||||
MonitoringEnabled: !config.MonitoringDisabled,
|
||||
|
||||
BackupOutputDir: config.BackupOutputDir,
|
||||
IncrBackupOutputDir: config.Backup.Backups && config.Backup.Backups["Cosmos Internal Backup"] ? config.Backup.Backups["Cosmos Internal Backup"].Repository : "",
|
||||
|
||||
AdminWhitelistIPs: config.AdminWhitelistIPs && config.AdminWhitelistIPs.join(', '),
|
||||
AdminConstellationOnly: config.AdminConstellationOnly,
|
||||
@@ -170,6 +171,10 @@ const ConfigManagement = () => {
|
||||
|
||||
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
if(!values.IncrBackupOutputDir) {
|
||||
delete config.Backup.Backups["Cosmos Internal Backup"];
|
||||
}
|
||||
|
||||
let toSave = {
|
||||
...config,
|
||||
@@ -243,6 +248,21 @@ const ConfigManagement = () => {
|
||||
PrimaryColor: values.PrimaryColor,
|
||||
SecondaryColor: values.SecondaryColor
|
||||
},
|
||||
Backup: {
|
||||
...config.Backup,
|
||||
Backups: values.IncrBackupOutputDir ? {
|
||||
...config.Backup.Backups,
|
||||
"Cosmos Internal Backup": {
|
||||
...config.Backup.Backups["Cosmos Internal Backup"],
|
||||
"Crontab": "0 0 4 * * *",
|
||||
"CrontabForget": "0 0 12 * * *",
|
||||
"Source": status && status.ConfigFolder,
|
||||
"Password": "",
|
||||
"Name": "Cosmos Internal Backup",
|
||||
Repository: values.IncrBackupOutputDir
|
||||
}
|
||||
} : config.Backup.Backups
|
||||
}
|
||||
}
|
||||
|
||||
return API.config.set(toSave).then((data) => {
|
||||
@@ -415,9 +435,13 @@ const ConfigManagement = () => {
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">{t('mgmt.config.general.backupInfo')}</Alert>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction={"row"} spacing={2} alignItems="flex-end">
|
||||
<FilePickerButton onPick={(path) => {
|
||||
<FilePickerButton canCreate onPick={(path) => {
|
||||
if(path)
|
||||
formik.setFieldValue('BackupOutputDir', path);
|
||||
}} size="150%" select="folder" />
|
||||
@@ -430,6 +454,22 @@ const ConfigManagement = () => {
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction={"row"} spacing={2} alignItems="flex-end">
|
||||
{status && status.Licence && <FilePickerButton canCreate onPick={(path) => {
|
||||
if(path)
|
||||
formik.setFieldValue('IncrBackupOutputDir', path);
|
||||
}} size="150%" select="folder" />}
|
||||
<CosmosInputText
|
||||
label={t('mgmt.config.general.backupDirInput.incrBackupDirLabel')}
|
||||
name="IncrBackupOutputDir"
|
||||
disabled={!status || !status.Licence}
|
||||
formik={formik}
|
||||
helperText={t('mgmt.config.general.backupDirInput.backupDirHelperText')}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel htmlFor="LoggingLevel-login">{t('mgmt.config.general.logLevelInput')}</InputLabel>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// material-ui
|
||||
import * as React from 'react';
|
||||
import { Alert, Button, Stack, Typography } from '@mui/material';
|
||||
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
@@ -34,6 +34,20 @@ function checkIsOnline() {
|
||||
});
|
||||
}
|
||||
|
||||
const RunningJobs = ({jobs}) => {
|
||||
return jobs ? (<>
|
||||
{jobs.length > 0 ? <>
|
||||
<Alert severity="warning" icon={<SyncOutlined />}>
|
||||
There are jobs running, the server will only restart once all jobs are finished.
|
||||
<ul>
|
||||
{jobs.map((job) => <li><LoadingOutlined /> {job}</li>)}
|
||||
</ul>
|
||||
</Alert>
|
||||
|
||||
</> : <></>}
|
||||
</>) : <></>;
|
||||
}
|
||||
|
||||
const RestartModal = ({openModal, setOpenModal, config, newRoute, isHostMachine }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
@@ -44,6 +58,53 @@ const RestartModal = ({openModal, setOpenModal, config, newRoute, isHostMachine
|
||||
let newRouteWarning = config && (config.HTTPConfig.HTTPSCertificateMode == "LETSENCRYPT" && newRoute &&
|
||||
(!config.HTTPConfig.DNSChallengeProvider || !config.HTTPConfig.UseWildcardCertificate))
|
||||
const [errorRestart, setErrorRestart] = useState(null);
|
||||
const [whenJobsIsEmpty, setWhenJobsIsEmpty] = useState(null);
|
||||
|
||||
const [jobs, setJobs] = useState(null);
|
||||
|
||||
const refresh = () => {
|
||||
if(!openModal) return;
|
||||
|
||||
API.cron.runningJobs().then((res) => {
|
||||
setJobs(res.data);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const interval = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [openModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestarting && jobs.length == 0) {
|
||||
if(isHostMachine) {
|
||||
API.restartServer()
|
||||
.then((res) => {
|
||||
}).catch((err) => {
|
||||
setErrorRestart(err.message);
|
||||
});
|
||||
} else {
|
||||
API.config.restart()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if(!errorRestart) {
|
||||
checkIsOnline();
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
setTimeout(() => {
|
||||
setWarn(true);
|
||||
}, 25000)
|
||||
}
|
||||
}, [jobs])
|
||||
|
||||
return config ? (<>
|
||||
{needsRefresh && <>
|
||||
@@ -78,50 +139,37 @@ const RestartModal = ({openModal, setOpenModal, config, newRoute, isHostMachine
|
||||
</>}
|
||||
</>)
|
||||
:(<>
|
||||
<Dialog open={openModal} onClose={() => setOpenModal(false)}>
|
||||
<Dialog open={openModal} onClose={() => {setJobs(null); return setOpenModal(false)}}>
|
||||
<DialogTitle>{!isRestarting ? t('mgmt.config.restart.restartTitle') : t('mgmt.config.restart.restartStatus')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{errorRestart && <Alert severity="error" icon={<ExclamationCircleOutlined />}>
|
||||
{errorRestart}
|
||||
</Alert>}
|
||||
{warn && !errorRestart && <div>
|
||||
<Alert severity="warning" icon={<WarningOutlined />}>
|
||||
{t('mgmt.config.restart.restartTimeoutWarning')}<br />{t('mgmt.config.restart.restartTimeoutWarningTip')}
|
||||
</Alert>
|
||||
</div>}
|
||||
{isRestarting && !errorRestart &&
|
||||
<div style={{textAlign: 'center', padding: '20px'}}>
|
||||
<CircularProgress />
|
||||
</div>}
|
||||
{!isRestarting && t('mgmt.config.restart.restartQuestion')}
|
||||
<Stack spacing={2}>
|
||||
{errorRestart && <Alert severity="error" icon={<ExclamationCircleOutlined />}>
|
||||
{errorRestart}
|
||||
</Alert>}
|
||||
{warn && !errorRestart && <div>
|
||||
<Alert severity="warning" icon={<WarningOutlined />}>
|
||||
{t('mgmt.config.restart.restartTimeoutWarning')}<br />{t('mgmt.config.restart.restartTimeoutWarningTip')}
|
||||
</Alert>
|
||||
</div>}
|
||||
<div>
|
||||
<RunningJobs jobs={jobs}/>
|
||||
</div>
|
||||
{isRestarting && !errorRestart &&
|
||||
<div style={{textAlign: 'center', padding: '20px'}}>
|
||||
<CircularProgress />
|
||||
</div>}
|
||||
<div>
|
||||
{!isRestarting && t('mgmt.config.restart.restartQuestion')}
|
||||
</div>
|
||||
</Stack>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
{!isRestarting && <DialogActions>
|
||||
<Button onClick={() => setOpenModal(false)}>{t('mgmt.config.restart.laterButton')}</Button>
|
||||
<Button onClick={() => {setJobs(null); return setOpenModal(false)}}>{t('mgmt.config.restart.laterButton')}</Button>
|
||||
<Button onClick={() => {
|
||||
setIsRestarting(true);
|
||||
setErrorRestart(null);
|
||||
|
||||
if(isHostMachine) {
|
||||
API.restartServer()
|
||||
.then((res) => {
|
||||
}).catch((err) => {
|
||||
setErrorRestart(err.message);
|
||||
});
|
||||
} else {
|
||||
API.config.restart()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if(!errorRestart) {
|
||||
checkIsOnline();
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
setTimeout(() => {
|
||||
setWarn(true);
|
||||
}, 20000)
|
||||
}}>{t('mgmt.servapps.actionBar.restart')}</Button>
|
||||
</DialogActions>}
|
||||
</Dialog>
|
||||
|
||||
@@ -103,6 +103,7 @@ export const CronManager = () => {
|
||||
"Custom": t('mgmt.scheduler.customJobsTitle'),
|
||||
"SnapRAID": t('mgmt.scheduler.parityDiskJobsTitle'),
|
||||
"__OT__SnapRAID": t('mgmt.scheduler.oneTimeJobsTitle'),
|
||||
"Restic": t('mgmt.scheduler.restic'),
|
||||
}[scheduler])}</h4>
|
||||
<PrettyTableView
|
||||
data={Object.values(cronJobs[scheduler])}
|
||||
|
||||
@@ -18,8 +18,9 @@ import DashboardDefault from '../pages/dashboard';
|
||||
import { CronManager } from '../pages/cron/jobsManage';
|
||||
import PrivateRoute from '../PrivateRoute';
|
||||
import TrustPage from '../pages/config/trust';
|
||||
import { Backups } from '../pages/backups';
|
||||
import AllBackupsIndex from '../pages/backups';
|
||||
import SingleBackupIndex from '../pages/backups/single-backup-index';
|
||||
import SingleRepoIndex from '../pages/backups/single-repo-index';
|
||||
|
||||
// ==============================|| MAIN ROUTING ||============================== //
|
||||
|
||||
@@ -47,12 +48,16 @@ const MainRoutes = {
|
||||
},
|
||||
{
|
||||
path: '/cosmos-ui/backups',
|
||||
element: <Backups />
|
||||
element: <AllBackupsIndex />
|
||||
},
|
||||
{
|
||||
path: '/cosmos-ui/backups/:backupName/',
|
||||
element: <SingleBackupIndex />
|
||||
},
|
||||
{
|
||||
path: '/cosmos-ui/backups/repo/:backupName/',
|
||||
element: <SingleRepoIndex />
|
||||
},
|
||||
{
|
||||
path: '/cosmos-ui/backups/:backupName/:subpath',
|
||||
element: <SingleBackupIndex />
|
||||
|
||||
@@ -156,10 +156,12 @@
|
||||
"mgmt.config.general.forceAutoUpdateButton": "Check for update now",
|
||||
"mgmt.config.general.backupDirInput.backupDirHelperText": "Directory where backups will be stored (relative to the host server `/`)",
|
||||
"mgmt.config.general.backupDirInput.backupDirLabel": "Backup Output Directory (relative to the host server `/`)",
|
||||
"mgmt.config.general.backupDirInput.incrBackupDirLabel": "Incremental Backup Output Directory (relative to the host server `/`)",
|
||||
"mgmt.config.general.configFileInfo": "This page allow you to edit the configuration file. Any Environment Variable overwritting configuration won't appear here.",
|
||||
"mgmt.config.general.forceMfaCheckbox.forceMfaHelperText": "Require MFA for all users",
|
||||
"mgmt.config.general.forceMfaCheckbox.forceMfaLabel": "Force Multi-Factor Authentication",
|
||||
"mgmt.config.general.logLevelInput": "Level of logging (Default: INFO)",
|
||||
"mgmt.config.general.backupInfo": "Both those options will allow you to backup your Cosmos config. The first one will simply output your config folder to a zip on each change. The second will create a proper incremental backup (with history) in a folder. The second option requires the backup feature to be unlocked. Both options are optionals",
|
||||
"mgmt.config.general.logLevelInput.logLevelValidation": "Logging Level is required",
|
||||
"mgmt.config.general.mongoDbInput": "MongoDB connection string. It is advised to use Environment variable to store this securely instead. (Optional)",
|
||||
"mgmt.config.general.monitoringCheckbox.monitoringLabel": "Monitoring Enabled",
|
||||
@@ -312,6 +314,7 @@
|
||||
"mgmt.openid.newMfa.tokenmax6charValidation": "Token must be at most 6 characters",
|
||||
"mgmt.openid.newMfa.tokenmin6charValidation": "Token must be at least 6 characters",
|
||||
"mgmt.openid.newMfa.wrongOtpValidation": "Wrong OTP. Try again",
|
||||
"mgmt.scheduler.restic": "Backup Jobs",
|
||||
"mgmt.scheduler.customJobsTitle": "Custom Jobs",
|
||||
"mgmt.scheduler.lastLogs": "Last logs for",
|
||||
"mgmt.scheduler.list.action.logs": "Logs",
|
||||
@@ -447,6 +450,9 @@
|
||||
"mgmt.servapps.viewStackButton": "View Stack",
|
||||
"mgmt.servapps.volumes.list.ScopeTitle": "Scope",
|
||||
"mgmt.servapps.volumes.volumeName": "Volume Name",
|
||||
"mgmt.storage.newFolder": "New Folder",
|
||||
"mgmt.storage.newFolderName": "New Folder Name",
|
||||
"mgmt.storage.newFolderIn": "Creating new folder in",
|
||||
"mgmt.storage.seeFile": "File Browser",
|
||||
"mgmt.storage.pickFile": "Pick a file/folder",
|
||||
"mgmt.storage.selected": "Selected",
|
||||
@@ -859,17 +865,24 @@
|
||||
"global.name": "Name",
|
||||
"global.password": "Password",
|
||||
"mgmt.backup.sourceTitle": "Source",
|
||||
"mgmt.backup.backups": "Backups",
|
||||
"mgmt.backup.snapshots": "Snapshots",
|
||||
"mgmt.backup.repositories": "Repositories",
|
||||
"mgmt.backup.repository.size": "Size",
|
||||
"mgmt.backup.repository.snapshots_count": "# Snapshots",
|
||||
"mgmt.backup.repository.total_file_count": "# Files",
|
||||
"mgmt.backup.repositoryTitle": "Repository",
|
||||
"mgmt.backup.scheduleTitle": "Schedule",
|
||||
"mgmt.backup.confirmBackupDeletion": "Are you sure you want to delete this backup?",
|
||||
"mgmt.backup.list.restoreText": "Restore",
|
||||
"mgmt.backup.newBackup": "New Backup",
|
||||
"mgmt.backup.createBackup": "Create Backup",
|
||||
"mgmt.backup.createBackupInfo": "Configure backup settings",
|
||||
"mgmt.backup.createBackupInfo": "Configure backup settings. All backups are encrypted using AES-256. You can safely re-use the same destination folder (repository) for many backups, the backed up files will be de-dupped, and Cosmos use a tagging system to tell backups appart in the repository. Cosmos uses Restic under the hood, so you can restore your backups even without Cosmos.",
|
||||
"mgmt.backup.min3chars": "Minimum 3 characters required",
|
||||
"mgmt.backup.min8chars": "Minimum 8 characters required",
|
||||
"mgmt.backup.source": "Source",
|
||||
"mgmt.backup.repository": "Repository",
|
||||
"mgmt.backup.schedule": "Crontab Backup Schedule",
|
||||
"mgmt.backup.restore": "Restore",
|
||||
"global.noData": "No Data To Show"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cosmos-server",
|
||||
"version": "0.18.0-unstable4",
|
||||
"version": "0.18.0-unstable7",
|
||||
"description": "",
|
||||
"main": "test-server.js",
|
||||
"bugs": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/azukaar/cosmos-server/src/storage"
|
||||
"github.com/azukaar/cosmos-server/src/docker"
|
||||
"github.com/azukaar/cosmos-server/src/proxy"
|
||||
"github.com/azukaar/cosmos-server/src/cron"
|
||||
|
||||
|
||||
"github.com/jasonlvhit/gocron"
|
||||
@@ -157,6 +158,8 @@ func checkUpdatesAvailable() {
|
||||
}
|
||||
}
|
||||
|
||||
cron.WaitForAllJobs() // wait for all jobs to finish
|
||||
|
||||
utils.Log("Update downloaded, restarting server")
|
||||
storage.StopAllRCloneProcess(true)
|
||||
os.Exit(0)
|
||||
|
||||
530
src/backups/API.go
Normal file
530
src/backups/API.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"github.com/gorilla/mux"
|
||||
"os"
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
)
|
||||
|
||||
func AddBackupRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "POST" {
|
||||
var request utils.SingleBackupConfig
|
||||
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||
utils.Error("AddBackup: Invalid request", err)
|
||||
utils.HTTPError(w, "Invalid request: "+err.Error(), http.StatusBadRequest, "BCK001")
|
||||
return
|
||||
}
|
||||
|
||||
config := utils.GetMainConfig()
|
||||
if _, exists := config.Backup.Backups[request.Name]; exists {
|
||||
utils.HTTPError(w, "Backup already exists", http.StatusBadRequest, "BCK002")
|
||||
return
|
||||
}
|
||||
|
||||
// Check repository status
|
||||
repoInfo, err := os.Stat(request.Repository)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
utils.Error("AddBackup: Failed to check repository", err)
|
||||
utils.HTTPError(w, "Failed to check repository: "+err.Error(), http.StatusInternalServerError, "BCK003")
|
||||
return
|
||||
}
|
||||
|
||||
var password string
|
||||
isNewRepo := false
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
isNewRepo = true
|
||||
} else if repoInfo.IsDir() {
|
||||
files, err := os.ReadDir(request.Repository)
|
||||
if err != nil {
|
||||
utils.Error("AddBackup: Failed to read repository directory", err)
|
||||
utils.HTTPError(w, "Failed to read repository directory: "+err.Error(), http.StatusInternalServerError, "BCK005")
|
||||
return
|
||||
}
|
||||
isNewRepo = len(files) == 0
|
||||
}
|
||||
|
||||
if isNewRepo {
|
||||
password = utils.GenerateRandomString(16)
|
||||
if err := CreateRepository(request.Repository, password); err != nil {
|
||||
utils.Error("AddBackup: Failed to create repository", err)
|
||||
utils.HTTPError(w, "Failed to create repository: "+err.Error(), http.StatusInternalServerError, "BCK004")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
found := false
|
||||
for _, backup := range config.Backup.Backups {
|
||||
if backup.Repository == request.Repository {
|
||||
password = backup.Password
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
utils.Error("AddBackup: Cannot find password for existing repository", nil)
|
||||
utils.HTTPError(w, "Repository exists but password not found", http.StatusBadRequest, "BCK006")
|
||||
return
|
||||
}
|
||||
|
||||
if err := CheckRepository(request.Repository, password); err != nil {
|
||||
utils.Error("AddBackup: Invalid repository", err)
|
||||
utils.HTTPError(w, "Invalid repository: "+err.Error(), http.StatusInternalServerError, "BCK007")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
request.Password = password
|
||||
if config.Backup.Backups == nil {
|
||||
config.Backup.Backups = make(map[string]utils.SingleBackupConfig)
|
||||
}
|
||||
config.Backup.Backups[request.Name] = request
|
||||
utils.SetBaseMainConfig(config)
|
||||
InitBackups()
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"message": fmt.Sprintf("Added backup %s", request.Name),
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func EditBackupRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "POST" {
|
||||
var request utils.SingleBackupConfig
|
||||
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||
utils.Error("EditBackup: Invalid request", err)
|
||||
utils.HTTPError(w, "Invalid request: "+err.Error(), http.StatusBadRequest, "BCK001")
|
||||
return
|
||||
}
|
||||
|
||||
config := utils.GetMainConfig()
|
||||
if _, exists := config.Backup.Backups[request.Name]; !exists {
|
||||
utils.HTTPError(w, "Backup does not exist", http.StatusBadRequest, "BCK002")
|
||||
return
|
||||
}
|
||||
|
||||
current := config.Backup.Backups[request.Name]
|
||||
|
||||
current.Crontab = request.Crontab
|
||||
current.CrontabForget = request.CrontabForget
|
||||
current.RetentionPolicy = request.RetentionPolicy
|
||||
|
||||
config.Backup.Backups[request.Name] = current
|
||||
utils.SetBaseMainConfig(config)
|
||||
InitBackups()
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"message": fmt.Sprintf("Edited backup %s", request.Name),
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveBackupRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "DELETE" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
var request struct {
|
||||
DeleteRepo bool `json:"deleteRepo"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||
utils.HTTPError(w, "Invalid request: " + err.Error(), http.StatusBadRequest, "BCK001")
|
||||
return
|
||||
}
|
||||
|
||||
config := utils.GetMainConfig()
|
||||
backup, exists := config.Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if other backups use same repository
|
||||
otherBackupsUsingRepo := false
|
||||
for n, b := range config.Backup.Backups {
|
||||
if n != name && b.Repository == backup.Repository {
|
||||
otherBackupsUsingRepo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if request.DeleteRepo {
|
||||
if otherBackupsUsingRepo {
|
||||
if err := DeleteByTag(backup.Repository, backup.Password, backup.Name); err != nil {
|
||||
utils.Error("RemoveBackup: Failed to delete snapshots", err)
|
||||
utils.HTTPError(w, "Failed to delete snapshots: " + err.Error(), http.StatusInternalServerError, "BCK005")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := DeleteRepository(backup.Repository); err != nil {
|
||||
utils.Error("RemoveBackup: Failed to delete repository", err)
|
||||
utils.HTTPError(w, "Failed to delete repository: " + err.Error(), http.StatusInternalServerError, "BCK006")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := DeleteByTag(backup.Repository, backup.Password, backup.Name); err != nil {
|
||||
utils.Error("RemoveBackup: Failed to delete snapshots", err)
|
||||
utils.HTTPError(w, "Failed to delete snapshots: " + err.Error(), http.StatusInternalServerError, "BCK005")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
delete(config.Backup.Backups, name)
|
||||
utils.SetBaseMainConfig(config)
|
||||
InitBackups()
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"message": fmt.Sprintf("Removed backup %s", name),
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func ListSnapshotsRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
|
||||
backup, exists := utils.GetMainConfig().Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
output, err := ListSnapshotsWithFilters(backup.Repository, backup.Password, []string{backup.Name}, "", "")
|
||||
if err != nil {
|
||||
utils.Error("ListSnapshots: Failed to list snapshots", err)
|
||||
utils.HTTPError(w, "Failed to list snapshots: "+err.Error(), http.StatusInternalServerError, "BCK006")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var outputJSON []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(output), &outputJSON); err != nil {
|
||||
utils.Error("ListSnapshots: Failed to parse snapshots", err)
|
||||
utils.HTTPError(w, "Failed to parse snapshots: "+err.Error(), http.StatusInternalServerError, "BCK007")
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": outputJSON,
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func ListFoldersRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
snapshot := vars["snapshot"]
|
||||
path := req.URL.Query().Get("path")
|
||||
|
||||
backup, exists := utils.GetMainConfig().Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
output, err := ListDirectory(backup.Repository, backup.Password, snapshot, path)
|
||||
if err != nil {
|
||||
utils.Error("ListFolders: Failed to list folders", err)
|
||||
utils.HTTPError(w, "Failed to list folders: "+err.Error(), http.StatusInternalServerError, "BCK007")
|
||||
return
|
||||
}
|
||||
|
||||
outputF := SplitJSONObjects(output)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var outputJSON []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(outputF), &outputJSON); err != nil {
|
||||
utils.Error("ListSnapshots: Failed to parse snapshots", err)
|
||||
utils.HTTPError(w, "Failed to parse snapshots: "+err.Error(), http.StatusInternalServerError, "BCK007")
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": outputJSON,
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func RestoreBackupRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "POST" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
|
||||
var request struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Target string `json:"target"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||
utils.Error("RestoreBackup: Invalid request", err)
|
||||
utils.HTTPError(w, "Invalid request: "+err.Error(), http.StatusBadRequest, "BCK008")
|
||||
return
|
||||
}
|
||||
|
||||
backup, exists := utils.GetMainConfig().Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify snapshot belongs to this backup
|
||||
snapshots, err := ListSnapshotsWithFilters(backup.Repository, backup.Password, []string{backup.Name}, "", "")
|
||||
if err != nil {
|
||||
utils.Error("RestoreBackup: Failed to verify snapshot", err)
|
||||
utils.HTTPError(w, "Failed to verify snapshot: "+err.Error(), http.StatusInternalServerError, "BCK009")
|
||||
return
|
||||
}
|
||||
|
||||
var snapshotsArray []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(snapshots), &snapshotsArray); err != nil {
|
||||
utils.Error("RestoreBackup: Failed to parse snapshots", err)
|
||||
utils.HTTPError(w, "Failed to parse snapshots: "+err.Error(), http.StatusInternalServerError, "BCK010")
|
||||
return
|
||||
}
|
||||
|
||||
snapshotFound := false
|
||||
for _, s := range snapshotsArray {
|
||||
if s["id"].(string) == request.SnapshotID {
|
||||
snapshotFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshotFound {
|
||||
utils.HTTPError(w, "Snapshot not found for this backup", http.StatusNotFound, "BCK011")
|
||||
return
|
||||
}
|
||||
|
||||
CreateRestoreJob(RestoreConfig{
|
||||
Repository: backup.Repository,
|
||||
Password: backup.Password,
|
||||
SnapshotID: request.SnapshotID,
|
||||
Target: request.Target,
|
||||
Name: backup.Name,
|
||||
Include: request.Include,
|
||||
OriginalSource: backup.Source,
|
||||
})
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"message": "Restore job created",
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func ListSnapshotsRouteFromRepo(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
|
||||
backup, exists := utils.GetMainConfig().Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
output, err := ListSnapshotsWithFilters(backup.Repository, backup.Password, []string{}, "", "")
|
||||
if err != nil {
|
||||
utils.Error("ListSnapshots: Failed to list snapshots", err)
|
||||
utils.HTTPError(w, "Failed to list snapshots: "+err.Error(), http.StatusInternalServerError, "BCK006")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var outputJSON []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(output), &outputJSON); err != nil {
|
||||
utils.Error("ListSnapshots: Failed to parse snapshots", err)
|
||||
utils.HTTPError(w, "Failed to parse snapshots: "+err.Error(), http.StatusInternalServerError, "BCK007")
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": outputJSON,
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func ListRepos(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
config := utils.GetMainConfig()
|
||||
repos := map[string]utils.SingleBackupConfig{}
|
||||
results := map[string]interface{}{}
|
||||
|
||||
for _, backup := range config.Backup.Backups {
|
||||
found := false
|
||||
if _, exists := repos[backup.Repository]; exists {
|
||||
found = true
|
||||
}
|
||||
|
||||
if !found {
|
||||
repos[backup.Repository] = backup
|
||||
|
||||
output, err := StatsRepository(backup.Repository, backup.Password)
|
||||
if err != nil {
|
||||
utils.Error("ListRepos: Failed to get repository stats", err)
|
||||
results[backup.Repository] = map[string]interface{}{
|
||||
"status": "error",
|
||||
"id": backup.Name,
|
||||
"error": err.Error(),
|
||||
}
|
||||
} else {
|
||||
var outputJSON map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(output), &outputJSON); err != nil {
|
||||
utils.Error("ListRepos: Failed to parse repository stats", err)
|
||||
results[backup.Repository] = map[string]interface{}{
|
||||
"status": "error",
|
||||
"id": backup.Name,
|
||||
"error": err.Error(),
|
||||
}
|
||||
} else {
|
||||
results[backup.Repository] = map[string]interface{}{
|
||||
"status": "ok",
|
||||
"id": backup.Name,
|
||||
"stats": outputJSON,
|
||||
"path": backup.Repository,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": results,
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func ForgetSnapshotRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "DELETE" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
snapshot := vars["snapshot"]
|
||||
|
||||
config := utils.GetMainConfig()
|
||||
backup, exists := config.Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
err := ForgetSnapshot(backup.Repository, backup.Password, snapshot)
|
||||
if err != nil {
|
||||
utils.Error("ForgetSnapshotRoute: Failed to forget snapshot", err)
|
||||
utils.HTTPError(w, "Failed to forget snapshot: "+err.Error(), http.StatusInternalServerError, "BCK008")
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"message": fmt.Sprintf("Forgot snapshot %s from repository %s", snapshot, backup.Repository),
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
|
||||
func StatsRepositorySubfolderRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
vars := mux.Vars(req)
|
||||
name := vars["name"]
|
||||
snapshot := vars["snapshot"]
|
||||
path := req.URL.Query().Get("path")
|
||||
|
||||
backup, exists := utils.GetMainConfig().Backup.Backups[name]
|
||||
if !exists {
|
||||
utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004")
|
||||
return
|
||||
}
|
||||
|
||||
output, err := StatsRepositorySubfolder(backup.Repository, backup.Password, snapshot, path)
|
||||
if err != nil {
|
||||
utils.Error("StatsRepositorySubfolder: Failed to get repository stats", err)
|
||||
utils.HTTPError(w, "Failed to get repository stats: "+err.Error(), http.StatusInternalServerError, "BCK009")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var outputJSON map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(output), &outputJSON); err != nil {
|
||||
utils.Error("StatsRepositorySubfolder: Failed to parse repository stats", err)
|
||||
utils.HTTPError(w, "Failed to parse repository stats: "+err.Error(), http.StatusInternalServerError, "BCK010")
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": outputJSON,
|
||||
})
|
||||
} else {
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
}
|
||||
}
|
||||
92
src/backups/index.go
Normal file
92
src/backups/index.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"github.com/azukaar/cosmos-server/src/cron"
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
)
|
||||
|
||||
func InitBackups() {
|
||||
config := utils.GetMainConfig()
|
||||
|
||||
if !utils.FBL.LValid {
|
||||
utils.Warn("InitBackups: No valid licence found, not starting module.")
|
||||
return
|
||||
}
|
||||
|
||||
Repositories := config.Backup.Backups
|
||||
|
||||
cron.ResetScheduler("Restic")
|
||||
|
||||
// check if "Cosmos Internal Backup" exists and is missing password
|
||||
// if so, create it
|
||||
internalBackup := ""
|
||||
intBack := utils.SingleBackupConfig{}
|
||||
password := ""
|
||||
|
||||
for _, repo := range Repositories {
|
||||
if repo.Name == "Cosmos Internal Backup" && repo.Password == "" {
|
||||
utils.Warn("[Backup] Cosmos Internal Backup is missing password, getting one.")
|
||||
internalBackup = repo.Repository
|
||||
intBack = repo
|
||||
}
|
||||
}
|
||||
|
||||
if internalBackup != "" {
|
||||
created := false
|
||||
for _, repo := range Repositories {
|
||||
if repo.Name != "Cosmos Internal Backup" && repo.Repository == internalBackup {
|
||||
utils.Log("[Backup] Found backup repository password.")
|
||||
password = repo.Password
|
||||
created = true
|
||||
}
|
||||
}
|
||||
|
||||
if !created {
|
||||
utils.Log("[Backup] Password not found. Creating one")
|
||||
password = utils.GenerateRandomString(16)
|
||||
}
|
||||
|
||||
|
||||
config.Backup.Backups["Cosmos Internal Backup"] = utils.SingleBackupConfig{
|
||||
Repository: internalBackup,
|
||||
Password: password,
|
||||
Source: intBack.Source,
|
||||
Crontab: intBack.Crontab,
|
||||
CrontabForget: intBack.CrontabForget,
|
||||
RetentionPolicy: intBack.RetentionPolicy,
|
||||
Name: "Cosmos Internal Backup",
|
||||
}
|
||||
|
||||
utils.SetBaseMainConfig(config)
|
||||
config = utils.GetMainConfig()
|
||||
}
|
||||
|
||||
|
||||
for _, repo := range Repositories {
|
||||
err := CheckRepository(repo.Repository, repo.Password)
|
||||
|
||||
if err != nil {
|
||||
utils.MajorError("Backups destination unavailable", err)
|
||||
} else {
|
||||
// create backup job
|
||||
CreateBackupJob(BackupConfig{
|
||||
Repository: repo.Repository,
|
||||
Password: repo.Password,
|
||||
Source: repo.Source,
|
||||
Name: repo.Name,
|
||||
Tags: []string{repo.Name},
|
||||
// Exclude: repo.Exclude,
|
||||
}, repo.Crontab)
|
||||
|
||||
// create backup job
|
||||
CreateForgetJob(BackupConfig{
|
||||
Repository: repo.Repository,
|
||||
Password: repo.Password,
|
||||
Source: repo.Source,
|
||||
Name: repo.Name,
|
||||
Tags: []string{repo.Name},
|
||||
Retention: repo.RetentionPolicy,
|
||||
}, repo.CrontabForget)
|
||||
}
|
||||
}
|
||||
}
|
||||
461
src/backups/restic.go
Normal file
461
src/backups/restic.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
"github.com/azukaar/cosmos-server/src/cron"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
var stripAnsi = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\]0;[^\a]*\a|\r`)
|
||||
|
||||
// SplitJSONObjects splits a string containing multiple JSON objects, respecting quotes
|
||||
func SplitJSONObjects(input string) string {
|
||||
var objects []string
|
||||
inQuotes := false
|
||||
escapeNext := false
|
||||
start := -1
|
||||
braceCount := 0
|
||||
|
||||
for i, char := range input {
|
||||
if escapeNext {
|
||||
escapeNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
switch char {
|
||||
case '"':
|
||||
if !escapeNext {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
case '\\':
|
||||
escapeNext = true
|
||||
case '{':
|
||||
if !inQuotes {
|
||||
if braceCount == 0 {
|
||||
start = i
|
||||
}
|
||||
braceCount++
|
||||
}
|
||||
case '}':
|
||||
if !inQuotes {
|
||||
braceCount--
|
||||
if braceCount == 0 && start != -1 {
|
||||
objects = append(objects, input[start:i+1])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "[" + strings.Join(objects, ",") + "]"
|
||||
}
|
||||
|
||||
// ExecRestic executes a restic command with the given arguments and environment variables
|
||||
func ExecRestic(args []string, env []string) (string, error) {
|
||||
cmd := exec.Command("./restic", args...)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
|
||||
// Start the command with a pseudo-terminal
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[Restic] failed to start command: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Create a buffer to store all output
|
||||
var output bytes.Buffer
|
||||
done := make(chan error, 1)
|
||||
|
||||
// Start a goroutine to read the output
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
output.Write(buf[:n])
|
||||
b := string(buf[:n])
|
||||
b = stripAnsi.ReplaceAllString(b, "")
|
||||
b = strings.TrimSuffix(b, "\n")
|
||||
utils.Debug("[Restic] " + b)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the command to finish
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
return output.String(), fmt.Errorf("[Restic] command failed: %w\nOutput: %s", err, output.String())
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
// CreateRepository initializes a new Restic repository
|
||||
func CreateRepository(repository, password string) error {
|
||||
args := []string{"init", "--repo", repository}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "created restic repository") {
|
||||
return fmt.Errorf("[Restic] failed to create repository: %s", output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRepository removes a Restic repository
|
||||
func DeleteRepository(repository string) error {
|
||||
// First, check if the repository exists
|
||||
if _, err := os.Stat(repository); os.IsNotExist(err) {
|
||||
return fmt.Errorf("[Restic] repository does not exist: %s", repository)
|
||||
}
|
||||
|
||||
// Remove the repository directory
|
||||
err := os.RemoveAll(repository)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[Restic] failed to delete repository: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditRepositoryPassword changes the password of an existing repository
|
||||
func EditRepositoryPassword(repository, currentPassword, newPassword string) error {
|
||||
args := []string{"key", "change", "--repo", repository}
|
||||
env := []string{
|
||||
fmt.Sprintf("RESTIC_PASSWORD=%s", currentPassword),
|
||||
fmt.Sprintf("RESTIC_NEW_PASSWORD=%s", newPassword),
|
||||
}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "changed password") {
|
||||
return fmt.Errorf("[Restic] failed to change password: %s", output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckRepository verifies if a repository exists and is valid
|
||||
func CheckRepository(repository, password string) error {
|
||||
args := []string{"check", "--repo", repository}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
_, err := ExecRestic(args, env)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckRepository verifies if a repository exists and is valid
|
||||
func StatsRepository(repository, password string) (string,error) {
|
||||
args := []string{"stats", "--repo", repository, "--json", "--mode", "raw-data"}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
return output,err
|
||||
}
|
||||
|
||||
// CheckRepository verifies if a repository exists and is valid
|
||||
func StatsRepositorySubfolder(repository, password, snapshot, path string) (string,error) {
|
||||
args := []string{"stats", "--mode", "restore-size", "--repo", repository, "--path", path, snapshot, "--json"}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
return output,err
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
Repository string
|
||||
Password string
|
||||
Source string
|
||||
Name string
|
||||
Tags []string
|
||||
Exclude []string
|
||||
Retention string
|
||||
}
|
||||
|
||||
// CreateBackupJob creates a backup job configuration
|
||||
func CreateBackupOneTimeJob(config BackupConfig) {
|
||||
utils.Log("Creating backup job for " + config.Name)
|
||||
|
||||
args := []string{"backup", "--repo", config.Repository, config.Source}
|
||||
|
||||
// Add tags if specified
|
||||
for _, tag := range config.Tags {
|
||||
args = append(args, "--tag", tag)
|
||||
}
|
||||
|
||||
// Add exclude patterns if specified
|
||||
for _, exclude := range config.Exclude {
|
||||
args = append(args, "--exclude", exclude)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("RESTIC_PASSWORD=%s", config.Password),
|
||||
}
|
||||
|
||||
cron.RunOneTimeJob(cron.ConfigJob{
|
||||
Scheduler: "Restic",
|
||||
Name: fmt.Sprintf("Restic backup %s", config.Name),
|
||||
Cancellable: true,
|
||||
Job: cron.JobFromCommandWithEnv(env, "./restic", args...),
|
||||
Resource: "backup@" + config.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBackupJob creates a backup job configuration
|
||||
func CreateBackupJob(config BackupConfig, crontab string) {
|
||||
utils.Log("Creating backup job for " + config.Name + " with crontab " + crontab)
|
||||
|
||||
args := []string{"backup", "--repo", config.Repository, config.Source}
|
||||
|
||||
// Add tags if specified
|
||||
for _, tag := range config.Tags {
|
||||
args = append(args, "--tag", tag)
|
||||
}
|
||||
|
||||
// Add exclude patterns if specified
|
||||
for _, exclude := range config.Exclude {
|
||||
args = append(args, "--exclude", exclude)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("RESTIC_PASSWORD=%s", config.Password),
|
||||
}
|
||||
|
||||
cron.RegisterJob(cron.ConfigJob{
|
||||
Scheduler: "Restic",
|
||||
Name: fmt.Sprintf("Restic backup %s", config.Name),
|
||||
Cancellable: true,
|
||||
Job: cron.JobFromCommandWithEnv(env, "./restic", args...),
|
||||
Crontab: crontab,
|
||||
Resource: "backup@" + config.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateForgetJob(config BackupConfig, crontab string) {
|
||||
utils.Log("Creating forget job for " + config.Name + " with crontab " + crontab)
|
||||
|
||||
args := []string{"forget", "--repo", config.Repository, "--prune"}
|
||||
|
||||
ret := config.Retention
|
||||
if ret == "" {
|
||||
ret = "--keep-last 3 --keep-daily 7 --keep-weekly 8 --keep-yearly 3"
|
||||
}
|
||||
|
||||
retArr := strings.Split(ret, " ")
|
||||
args = append(args, retArr...)
|
||||
|
||||
// Add tags if specified
|
||||
for _, tag := range config.Tags {
|
||||
args = append(args, "--tag", tag)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("RESTIC_PASSWORD=%s", config.Password),
|
||||
}
|
||||
|
||||
cron.RegisterJob(cron.ConfigJob{
|
||||
Scheduler: "Restic",
|
||||
Name: fmt.Sprintf("Restic forget %s", config.Name),
|
||||
Cancellable: true,
|
||||
Job: cron.JobFromCommandWithEnv(env, "./restic", args...),
|
||||
Crontab: crontab,
|
||||
Resource: "backup@" + config.Name,
|
||||
})
|
||||
}
|
||||
|
||||
type RestoreConfig struct {
|
||||
Repository string
|
||||
Password string
|
||||
SnapshotID string
|
||||
Target string
|
||||
Name string
|
||||
Include []string
|
||||
OriginalSource string
|
||||
}
|
||||
|
||||
// CreateRestoreJob creates a restore job configuration
|
||||
func CreateRestoreJob(config RestoreConfig) {
|
||||
args := []string{
|
||||
"restore",
|
||||
"--repo", config.Repository,
|
||||
config.SnapshotID + ":/" + config.OriginalSource,
|
||||
"--target", config.Target,
|
||||
}
|
||||
|
||||
// Add include patterns if specified
|
||||
for _, include := range config.Include {
|
||||
//remove OriginalSource from include path
|
||||
include := strings.TrimPrefix(include, config.OriginalSource)
|
||||
utils.Debug("[RESTIC] Restore includes: " + include)
|
||||
args = append(args, "--include", include)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("RESTIC_PASSWORD=%s", config.Password),
|
||||
}
|
||||
|
||||
go (func() {
|
||||
cron.RunOneTimeJob(cron.ConfigJob{
|
||||
Scheduler: "Restic",
|
||||
Name: fmt.Sprintf("Restic restore %s", config.Name),
|
||||
Cancellable: true,
|
||||
Job: cron.JobFromCommandWithEnv(env, "./restic", args...),
|
||||
Resource: "backup@" + config.Name,
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
// ListSnapshots returns a list of all snapshots in the repository
|
||||
func ListSnapshots(repository, password string) (string, error) {
|
||||
args := []string{
|
||||
"snapshots",
|
||||
"--repo", repository,
|
||||
"--json", // Use JSON format for easier parsing if needed
|
||||
}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[Restic] failed to list snapshots: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ListSnapshotsWithFilters returns a filtered list of snapshots
|
||||
func ListSnapshotsWithFilters(repository, password string, tags []string, host string, path string) (string, error) {
|
||||
args := []string{
|
||||
"snapshots",
|
||||
"--repo", repository,
|
||||
"--json",
|
||||
}
|
||||
|
||||
// Add optional filters
|
||||
for _, tag := range tags {
|
||||
args = append(args, "--tag", tag)
|
||||
}
|
||||
if host != "" {
|
||||
args = append(args, "--host", host)
|
||||
}
|
||||
if path != "" {
|
||||
args = append(args, "--path", path)
|
||||
}
|
||||
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[Restic] failed to list filtered snapshots: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ListDirectory lists the contents of a directory in a specific snapshot
|
||||
func ListDirectory(repository, password, snapshotID, path string) (string, error) {
|
||||
args := []string{
|
||||
"ls",
|
||||
"--repo", repository,
|
||||
snapshotID,
|
||||
path,
|
||||
"--json", // Use JSON format for easier parsing if needed
|
||||
}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[Restic] failed to list directory contents: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ListDirectoryWithFilters lists directory contents with additional filters
|
||||
func ListDirectoryWithFilters(repository, password, snapshotID, path string, recursive bool, longFormat bool) (string, error) {
|
||||
args := []string{
|
||||
"ls",
|
||||
"--repo", repository,
|
||||
snapshotID,
|
||||
path,
|
||||
"--json",
|
||||
}
|
||||
|
||||
if recursive {
|
||||
args = append(args, "--recursive")
|
||||
}
|
||||
if longFormat {
|
||||
args = append(args, "--long")
|
||||
}
|
||||
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
output, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[Restic] failed to list directory contents with filters: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func DeleteByTag(repository string, password string, tag string) error {
|
||||
// First get all snapshots with this tag
|
||||
output, err := ListSnapshotsWithFilters(repository, password, []string{tag}, "", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("[Restic] failed to list snapshots for deletion: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON output to get snapshot IDs
|
||||
var snapshots []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(output), &snapshots); err != nil {
|
||||
return fmt.Errorf("[Restic] failed to parse snapshots: %w", err)
|
||||
}
|
||||
|
||||
// Extract all snapshot IDs
|
||||
var ids []string
|
||||
for _, snap := range snapshots {
|
||||
ids = append(ids, snap["id"].(string))
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil // No snapshots to delete
|
||||
}
|
||||
|
||||
// Create forget command with all snapshot IDs
|
||||
args := append([]string{"forget", "--prune"}, ids...)
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
// Execute the forget command
|
||||
_, err = ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[Restic] failed to delete snapshots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ForgetSnapshot(repository, password, snapshot string) error {
|
||||
args := []string{"forget", snapshot, "--prune", "--repo", repository}
|
||||
env := []string{fmt.Sprintf("RESTIC_PASSWORD=%s", password)}
|
||||
|
||||
_, err := ExecRestic(args, env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[Restic] failed to forget snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/azukaar/cosmos-server/src/constellation"
|
||||
"github.com/azukaar/cosmos-server/src/cron"
|
||||
"github.com/azukaar/cosmos-server/src/storage"
|
||||
"github.com/azukaar/cosmos-server/src/backups"
|
||||
)
|
||||
|
||||
func ConfigApiSet(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -60,6 +61,7 @@ func ConfigApiSet(w http.ResponseWriter, req *http.Request) {
|
||||
utils.RestartHTTPServer()
|
||||
cron.InitJobs()
|
||||
cron.InitScheduler()
|
||||
backups.InitBackups()
|
||||
})()
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
|
||||
@@ -206,6 +206,25 @@ func GetJobRoute(w http.ResponseWriter, req *http.Request) {
|
||||
"data": jobs[scheduler][job],
|
||||
})
|
||||
|
||||
return
|
||||
} else {
|
||||
utils.Error("Listjobs: Method not allowed " + req.Method, nil)
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func GetRunningJobsRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "GET" {
|
||||
jobs := RunningJobs()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": jobs,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
utils.Error("Listjobs: Method not allowed " + req.Method, nil)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"os"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/creack/pty"
|
||||
@@ -40,11 +41,13 @@ type ConfigJob struct {
|
||||
CancelFunc context.CancelFunc `json:"-"`
|
||||
Container string
|
||||
Timeout time.Duration
|
||||
MaxLogs int
|
||||
MaxLogs int
|
||||
Resource string
|
||||
}
|
||||
|
||||
var jobsList = map[string]map[string]ConfigJob{}
|
||||
var wasInit = false
|
||||
var InternalProcessTracker = NewProcessTracker()
|
||||
|
||||
func GetJobsList() map[string]map[string]ConfigJob {
|
||||
return getJobsList()
|
||||
@@ -75,6 +78,18 @@ func getJobsList() map[string]map[string]ConfigJob {
|
||||
return jobsList
|
||||
}
|
||||
|
||||
func RunningJobs() []string {
|
||||
runningJobs := []string{}
|
||||
for _, schedulerList := range jobsList {
|
||||
for _, job := range schedulerList {
|
||||
if job.Running {
|
||||
runningJobs = append(runningJobs, job.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return runningJobs
|
||||
}
|
||||
|
||||
func JobFromCommand(command string, args ...string) func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
|
||||
return JobFromCommandWithEnv([]string{}, command, args...)
|
||||
}
|
||||
@@ -282,6 +297,7 @@ func jobRunner(schedulerName, jobName string) func(OnLog func(string), OnFail fu
|
||||
defer func() { <-RunningLock }()
|
||||
|
||||
CRONLock <- true
|
||||
|
||||
var job ConfigJob
|
||||
var ok bool
|
||||
|
||||
@@ -325,8 +341,12 @@ func jobRunner(schedulerName, jobName string) func(OnLog func(string), OnFail fu
|
||||
cancel()
|
||||
}()
|
||||
|
||||
triggerJobUpdated("start", job.Name)
|
||||
triggerJobEvent(job, "started", "CRON job " + job.Name + " started", "info", map[string]interface{}{})
|
||||
|
||||
triggerJobUpdated("start", job.Name)
|
||||
|
||||
InternalProcessTracker.StartProcess()
|
||||
|
||||
job.Job(OnLog, OnFail, OnSuccess, ctx, cancel)
|
||||
}
|
||||
}
|
||||
@@ -363,6 +383,12 @@ func jobRunner_OnFail(schedulerName, jobName string) func(err error) {
|
||||
jobsList[job.Scheduler][job.Name] = job
|
||||
triggerJobUpdated("fail", job.Name, err.Error())
|
||||
utils.MajorError("CRON job " + job.Name + " failed", err)
|
||||
|
||||
InternalProcessTracker.EndProcess()
|
||||
|
||||
triggerJobEvent(job, "fail", "CRON job " + job.Name + " failed", "error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
<-CRONLock
|
||||
}
|
||||
@@ -377,12 +403,53 @@ func jobRunner_OnSuccess(schedulerName, jobName string) func() {
|
||||
jobsList[job.Scheduler][job.Name] = job
|
||||
triggerJobUpdated("success", job.Name)
|
||||
utils.Log("CRON job " + job.Name + " finished")
|
||||
|
||||
InternalProcessTracker.EndProcess()
|
||||
|
||||
triggerJobEvent(job, "success", "CRON job " + job.Name + " finished", "success", map[string]interface{}{})
|
||||
}
|
||||
<-CRONLock
|
||||
}
|
||||
}
|
||||
|
||||
func WaitForAllJobs() {
|
||||
utils.Log("Waiting for " + strconv.Itoa(InternalProcessTracker.count) + " jobs to finish...")
|
||||
InternalProcessTracker.WaitForZero()
|
||||
}
|
||||
|
||||
func triggerJobEvent(job ConfigJob, eventId, eventTitle, eventLevel string, extra map[string]interface{}) {
|
||||
utils.TriggerEvent(
|
||||
"cosmos.cron." + strings.Replace(job.Scheduler, ".", "_", -1) + "." + strings.Replace(job.Name, ".", "_", -1) + "." + eventId,
|
||||
eventTitle,
|
||||
eventLevel,
|
||||
"job@" + job.Scheduler + "@" + job.Name,
|
||||
extra,
|
||||
)
|
||||
|
||||
if job.Resource != "" {
|
||||
utils.TriggerEvent(
|
||||
"cosmos.cron-resource." + strings.Replace(job.Scheduler, ".", "_", -1) + "." + strings.Replace(job.Name, ".", "_", -1) + "." + eventId,
|
||||
eventTitle,
|
||||
eventLevel,
|
||||
job.Resource,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
|
||||
if job.Container != "" {
|
||||
utils.TriggerEvent(
|
||||
"cosmos.cron-container." + strings.Replace(job.Scheduler, ".", "_", -1) + "." + strings.Replace(job.Name, ".", "_", -1) + "." + eventId,
|
||||
eventTitle,
|
||||
eventLevel,
|
||||
"container@" + job.Container,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func InitScheduler() {
|
||||
utils.WaitForAllJobs = WaitForAllJobs
|
||||
|
||||
var err error
|
||||
|
||||
if !wasInit {
|
||||
|
||||
42
src/cron/tracker.go
Normal file
42
src/cron/tracker.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ProcessTracker struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
zeroCh chan struct{}
|
||||
}
|
||||
|
||||
func NewProcessTracker() *ProcessTracker {
|
||||
return &ProcessTracker{
|
||||
zeroCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProcessTracker) StartProcess() {
|
||||
pt.mu.Lock()
|
||||
pt.count++
|
||||
pt.mu.Unlock()
|
||||
}
|
||||
|
||||
func (pt *ProcessTracker) EndProcess() {
|
||||
pt.mu.Lock()
|
||||
pt.count--
|
||||
if pt.count == 0 {
|
||||
select {
|
||||
case pt.zeroCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
pt.mu.Unlock()
|
||||
}
|
||||
|
||||
func (pt *ProcessTracker) WaitForZero() {
|
||||
if pt.count == 0 {
|
||||
return
|
||||
}
|
||||
<-pt.zeroCh
|
||||
}
|
||||
@@ -545,6 +545,7 @@ func InitServer() *mux.Router {
|
||||
srapiAdmin.HandleFunc("/api/jobs/run", cron.RunJobRoute)
|
||||
srapiAdmin.HandleFunc("/api/jobs/get", cron.GetJobRoute)
|
||||
srapiAdmin.HandleFunc("/api/jobs/delete", cron.DeleteJobRoute)
|
||||
srapiAdmin.HandleFunc("/api/jobs/running", cron.GetRunningJobsRoute)
|
||||
|
||||
srapiAdmin.HandleFunc("/api/smart-def", storage.ListSmartDef)
|
||||
srapiAdmin.HandleFunc("/api/disks", storage.ListDisksRoute)
|
||||
@@ -558,12 +559,18 @@ func InitServer() *mux.Router {
|
||||
srapiAdmin.HandleFunc("/api/snapraid/{name}/{action}", storage.SnapRAIDRunRoute)
|
||||
srapiAdmin.HandleFunc("/api/rclone-restart", storage.API_Rclone_remountAll)
|
||||
srapiAdmin.HandleFunc("/api/list-dir", storage.ListDirectoryRoute)
|
||||
srapiAdmin.HandleFunc("/api/new-dir", storage.CreateFolderRoute)
|
||||
|
||||
srapiAdmin.HandleFunc("/api/backups-repository", backups.ListRepos)
|
||||
srapiAdmin.HandleFunc("/api/backups-repository/{name}/snapshots", backups.ListSnapshotsRouteFromRepo)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}/snapshots", backups.ListSnapshotsRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}/{snapshot}/folders", backups.ListFoldersRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}/restore", backups.RestoreBackupRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups", backups.AddBackupRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/edit", backups.EditBackupRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}", backups.RemoveBackupRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}/{snapshot}/forget", backups.ForgetSnapshotRoute)
|
||||
srapiAdmin.HandleFunc("/api/backups/{name}/{snapshot}/subfolder-restore-size", backups.StatsRepositorySubfolderRoute)
|
||||
|
||||
// srapiAdmin.HandleFunc("/api/storage/raid", storage.RaidListRoute).Methods("GET")
|
||||
// srapiAdmin.HandleFunc("/api/storage/raid", storage.RaidCreateRoute).Methods("POST")
|
||||
|
||||
@@ -295,7 +295,7 @@ func cosmos() {
|
||||
// Has to be done last, so scheduler does not re-init
|
||||
cron.Init()
|
||||
|
||||
backups.InitBackups()
|
||||
go backups.InitBackups()
|
||||
|
||||
utils.Log("Starting server...")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io/ioutil"
|
||||
"syscall"
|
||||
"strings"
|
||||
"os"
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
)
|
||||
@@ -154,4 +155,79 @@ func ListDirectory(path string) ([]DirectoryListing, error) {
|
||||
}
|
||||
|
||||
return listings, nil
|
||||
}
|
||||
|
||||
func CreateFolderRoute(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == "POST" {
|
||||
//config := utils.GetMainConfig()
|
||||
storage := req.URL.Query().Get("storage")
|
||||
path := req.URL.Query().Get("path")
|
||||
folder := req.URL.Query().Get("folder")
|
||||
|
||||
if storage == "" {
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
storages, err := ListStorage()
|
||||
if err != nil {
|
||||
utils.Error("CreateFolderRoute: Error listing storages: "+err.Error(), nil)
|
||||
utils.HTTPError(w, "Internal server error", http.StatusInternalServerError, "STO002")
|
||||
return
|
||||
}
|
||||
|
||||
var basePath string
|
||||
if storage == "local" {
|
||||
basePath = "/"
|
||||
if utils.IsInsideContainer {
|
||||
basePath = "/mnt/host/"
|
||||
}
|
||||
} else {
|
||||
found := false
|
||||
for _, s := range storages {
|
||||
if s.Name == storage {
|
||||
basePath = s.Path
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
utils.Error("CreateFolderRoute: Storage not found: "+storage, nil)
|
||||
utils.HTTPError(w, "Storage not found", http.StatusNotFound, "STO001")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(basePath, path)
|
||||
fullPath = filepath.Join(fullPath, folder)
|
||||
|
||||
utils.Log("CreateFolderRoute: Creating folder: "+fullPath)
|
||||
|
||||
err = os.MkdirAll(fullPath, 0700)
|
||||
if err != nil {
|
||||
utils.Error("CreateFolderRoute: Error listing directory: "+err.Error(), nil)
|
||||
utils.HTTPError(w, "Internal server error", http.StatusInternalServerError, "STO003")
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": map[string]interface{}{
|
||||
"storage": storage,
|
||||
"path": path,
|
||||
"storages": storages,
|
||||
"created": fullPath,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
utils.Error("CreateFolderRoute: Method not allowed "+req.Method, nil)
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -460,6 +460,7 @@ func getStorageList() ([]RemoteStorage, error) {
|
||||
return nil, fmt.Errorf("error getting config dump: %w", err)
|
||||
}
|
||||
|
||||
CachedRemoteStorageList = []StorageInfo{}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(response, &result); err != nil {
|
||||
@@ -621,6 +622,8 @@ type StorageRoutes struct {
|
||||
var StorageRoutesList []StorageRoutes
|
||||
|
||||
func remountAll() {
|
||||
utils.WaitForAllJobs()
|
||||
|
||||
StorageRoutesList = []StorageRoutes{}
|
||||
|
||||
// Mount remote storages
|
||||
@@ -684,6 +687,8 @@ func API_Rclone_remountAll(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func InitRemoteStorage() bool {
|
||||
utils.StopAllRCloneProcess = StopAllRCloneProcess
|
||||
|
||||
configLocation := utils.CONFIGFOLDER + "rclone.conf"
|
||||
utils.ProxyRCloneUser = utils.GenerateRandomString(8)
|
||||
utils.ProxyRClonePwd = utils.GenerateRandomString(16)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/cpu"
|
||||
|
||||
@@ -56,6 +57,9 @@ func StatusRoute(w http.ResponseWriter, req *http.Request) {
|
||||
licenceNumber = utils.FBL.UserNumber
|
||||
}
|
||||
|
||||
absoluteConfigPath := utils.CONFIGFOLDER
|
||||
absoluteConfigPath, _ = filepath.Abs(utils.CONFIGFOLDER)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": map[string]interface{}{
|
||||
@@ -87,6 +91,7 @@ func StatusRoute(w http.ResponseWriter, req *http.Request) {
|
||||
"MonitoringDisabled": utils.GetMainConfig().MonitoringDisabled,
|
||||
"Licence": licenceValid,
|
||||
"LicenceNumber": licenceNumber,
|
||||
"ConfigFolder": absoluteConfigPath,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -233,7 +238,7 @@ func restartHostMachineRoute(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// restart host machine
|
||||
utils.WaitForAllJobs()
|
||||
|
||||
err := restartHostMachine()
|
||||
if err != nil {
|
||||
|
||||
@@ -100,6 +100,7 @@ type Config struct {
|
||||
MonitoringDisabled bool
|
||||
MonitoringAlerts map[string]Alert
|
||||
BackupOutputDir string
|
||||
IncrBackupOutputDir string
|
||||
DisableHostModeWarning bool
|
||||
AdminWhitelistIPs []string
|
||||
AdminConstellationOnly bool
|
||||
@@ -449,4 +450,6 @@ type SingleBackupConfig struct {
|
||||
Password string
|
||||
Source string
|
||||
Crontab string
|
||||
CrontabForget string
|
||||
RetentionPolicy string
|
||||
}
|
||||
@@ -58,6 +58,8 @@ var RestartHTTPServer = func() {}
|
||||
var GetContainerIPByName func(string) (string, error)
|
||||
var DoesContainerExist func(string) bool
|
||||
var CheckDockerNetworkMode func() string
|
||||
var WaitForAllJobs func()
|
||||
var StopAllRCloneProcess func(bool)
|
||||
|
||||
var ResyncConstellationNodes = func() {}
|
||||
|
||||
@@ -429,6 +431,8 @@ func SaveConfigTofile(config Config) {
|
||||
|
||||
func RestartServer() {
|
||||
Log("Restarting server...")
|
||||
WaitForAllJobs()
|
||||
StopAllRCloneProcess(false)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user