1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

feat: add backup restoring from the error dialog

This commit is contained in:
Ash Keel 2023-04-18 15:05:11 +02:00
parent 4e9b2cff7d
commit 42e8c34702
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
11 changed files with 297 additions and 91 deletions

56
app.go
View file

@ -6,21 +6,22 @@ import (
"errors"
"fmt"
"io"
"log"
"mime/multipart"
nethttp "net/http"
"os"
"runtime/debug"
"strconv"
"github.com/strimertul/strimertul/docs"
"git.sr.ht/~hamcha/containers/sync"
"github.com/nicklaw5/helix/v2"
"github.com/postfinance/single"
"github.com/urfave/cli/v2"
"github.com/wailsapp/wails/v2/pkg/runtime"
"go.uber.org/zap"
"github.com/strimertul/strimertul/database"
"github.com/strimertul/strimertul/docs"
"github.com/strimertul/strimertul/http"
"github.com/strimertul/strimertul/loyalty"
"github.com/strimertul/strimertul/twitch"
@ -29,6 +30,7 @@ import (
// App struct
type App struct {
ctx context.Context
lock *single.Single
cliParams *cli.Context
driver database.DatabaseDriver
ready *sync.RWSync[bool]
@ -52,6 +54,21 @@ func NewApp(cliParams *cli.Context) *App {
// startup is called when the app starts
func (a *App) startup(ctx context.Context) {
// Ensure only one copy of strimertul is running at all times
var err error
a.lock, err = single.New("strimertul")
if err != nil {
log.Fatal(err)
}
if err = a.lock.Lock(); err != nil {
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "strimertul is already running",
Message: "Only one copy of strimertul can run at the same time, make sure to close other instances first",
})
log.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
a.stop(ctx)
@ -73,7 +90,6 @@ func (a *App) startup(ctx context.Context) {
}
// Make KV hub
var err error
a.driver, err = database.GetDatabaseDriver(a.cliParams)
if err != nil {
a.showFatalError(err, "Error opening database")
@ -137,6 +153,9 @@ func (a *App) startup(ctx context.Context) {
}
func (a *App) stop(context.Context) {
if a.lock != nil {
warnOnError(a.lock.Unlock(), "could not remove lock file")
}
if a.loyaltyManager != nil {
warnOnError(a.loyaltyManager.Close(), "could not cleanly close loyalty manager")
}
@ -231,37 +250,6 @@ func (a *App) SendCrashReport(errorData string, info string) (string, error) {
return string(byt), err
}
type BackupInfo struct {
Filename string `json:"filename"`
Date int64 `json:"date"`
}
func (a *App) GetBackups() (list []BackupInfo) {
files, err := os.ReadDir(a.backupOptions.BackupDir)
if err != nil {
logger.Error("could not read backup directory", zap.Error(err))
return nil
}
for _, file := range files {
if file.IsDir() {
continue
}
info, err := file.Info()
if err != nil {
logger.Error("could not get info for backup file", zap.Error(err))
continue
}
list = append(list, BackupInfo{
Filename: file.Name(),
Date: info.ModTime().UnixMilli(),
})
}
return
}
type VersionInfo struct {
Release string `json:"release"`
BuildInfo *debug.BuildInfo `json:"build"`

View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
@ -62,3 +63,62 @@ func BackupTask(driver database.DatabaseDriver, options database.BackupOptions)
}
}
}
type BackupInfo struct {
Filename string `json:"filename"`
Date int64 `json:"date"`
Size int64 `json:"size"`
}
func (a *App) GetBackups() (list []BackupInfo) {
files, err := os.ReadDir(a.backupOptions.BackupDir)
if err != nil {
logger.Error("could not read backup directory", zap.Error(err))
return nil
}
for _, file := range files {
if file.IsDir() {
continue
}
info, err := file.Info()
if err != nil {
logger.Error("could not get info for backup file", zap.Error(err))
continue
}
list = append(list, BackupInfo{
Filename: file.Name(),
Date: info.ModTime().UnixMilli(),
Size: info.Size(),
})
}
return
}
func (a *App) RestoreBackup(backupName string) error {
path := filepath.Join(a.backupOptions.BackupDir, backupName)
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("could not open import file for reading: %w", err)
}
defer utils.Close(file, logger)
inStream := file
if a.driver == nil {
a.driver, err = database.GetDatabaseDriver(a.cliParams)
if err != nil {
return fmt.Errorf("could not open database: %w", err)
}
}
err = a.driver.Restore(inStream)
if err != nil {
return fmt.Errorf("could not restore database: %w", err)
}
logger.Info("restored database from backup")
return nil
}

View file

@ -55,7 +55,11 @@ func GetDatabaseDriver(ctx *cli.Context) (DatabaseDriver, error) {
case "badger":
return nil, cli.Exit("Badger is not supported anymore as a database driver", 64)
case "pebble":
return NewPebble(dbDirectory, logger)
db, err := NewPebble(dbDirectory, logger)
if err != nil {
return nil, cli.Exit(err.Error(), 64)
}
return db, nil
default:
return nil, cli.Exit(fmt.Sprintf("Unknown database driver: %s", name), 64)
}

View file

@ -363,6 +363,19 @@
"transparency-user": "The additional info below, if any was provided",
"error-message": "The crash report could not be submitted because of a remote error: {{error}}",
"post-report": "The error was successfully reported and has been assigned the following code: <m>{{code}}</m> If you haven't provided an email and want to follow up on this, use that code when opening an issue or reaching out."
},
"recovery": {
"restore-error": "The database could not be restored because of the following error: {{error}}",
"title": "Recovery options",
"text-head": "These action will irreversibly modify your database, please make sure your database is corrupted in the first place before proceeding.",
"restore-head": "Restore from backup",
"restore-desc-1": "Restore a previously backed up database. This will overwrite your current database with the saved copy. Check below for the list of saved copies.",
"restore-button": "Restore",
"restore-confirm-title": "Confirm database restore",
"restore-confirm-body": "Restoring this backup will overwrite your current database, this operation is irreversible.",
"restore-failed": "Restore failed",
"restore-succeeded-title": "Database restored",
"restore-succeeded-body": "The database was restored from the chosen backup, please close and re-open {{APPNAME}}."
}
}
},

View file

@ -413,7 +413,22 @@
"transparency-files": "I contenuti di <m>{{A}}</m> e <m>{{B}}</m>",
"transparency-info": "Informazioni sull'errore specifico che ha causato il crash",
"transparency-line": "Facendo clic su \"Invia segnalazione\", verranno raccolte e inviate i seguenti dati:",
"transparency-user": "Le informazioni aggiuntive di seguito, se fornite"
"transparency-user": "Le informazioni aggiuntive di seguito, se fornite",
"error-message": "Non è stato possibile inviare la segnalazione di errore a causa di un errore remoto: {{error}}",
"post-report": "L'errore è stato segnalato con successo ed è stato assegnato il seguente codice: <m>{{code}}</m> Se non hai fornito un'e-mail e vuoi contattarci in merito, usa quel codice quando apri una segnalazione o altro contatto."
},
"recovery": {
"restore-button": "Ripristina",
"restore-confirm-body": "Il ripristino di questo backup sovrascriverà il database attuale, questa operazione è irreversibile.",
"restore-confirm-title": "Conferma ripristino del database",
"restore-desc-1": "Ripristina il database utilizzando un backup creato precedentemente. \nQuesto sovrascriverà il database attuale con la copia salvata. \nQui di seguito è la lista di tutti i backup attualmente salvati.",
"restore-error": "Impossibile ripristinare il database a causa del seguente errore: {{error}}",
"restore-failed": "Ripristino non riuscito",
"restore-head": "Ripristina dal backup",
"text-head": "Queste azioni modificheranno irreversibilmente il database, assicurati che il database sia effettivamente danneggiato prima di procedere.",
"title": "Opzioni di ripristino",
"restore-succeeded-title": "Database ripristinato",
"restore-succeeded-body": "Il database è stato ripristinato dal backup scelto, chiudi e riapri {{APPNAME}}."
}
}
},

View file

@ -2,11 +2,12 @@ import { CheckIcon } from '@radix-ui/react-icons';
import {
GetBackups,
GetLastLogs,
RestoreBackup,
SendCrashReport,
} from '@wailsapp/go/main/App';
import type { main } from '@wailsapp/go/models';
import { EventsOff, EventsOn } from '@wailsapp/runtime';
import { Fragment, useEffect, useState } from 'react';
import React, { Fragment, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { languages } from '~/locale/languages';
import { ProcessedLogEntry, processEntry } from '~/store/logging/reducer';
@ -33,7 +34,7 @@ import {
TextBlock,
} from '~/ui/theme';
import AlertContent from './components/AlertContent';
import { Alert, AlertDescription } from './theme/alert';
import { Alert, AlertDescription, AlertTrigger } from './theme/alert';
const Container = styled('div', {
position: 'relative',
@ -99,26 +100,34 @@ const LanguageItem = styled(MultiToggleItem, {
});
const BackupItem = styled('article', {
marginBottom: '0.4rem',
backgroundColor: '$gray2',
margin: '0.5rem 0',
padding: '0.3rem 0.5rem',
borderLeft: '5px solid $teal8',
padding: '0.3rem 1rem 0.3rem 0.5rem',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
borderBottom: '1px solid $gray5',
transition: 'all 50ms',
display: 'flex',
'&:nth-child(odd)': {
backgroundColor: '$gray3',
},
gap: '0.5rem',
});
const BackupDate = styled('div', {
flex: '1',
display: 'flex',
alignItems: 'center',
flex: '1',
gap: '0.5rem',
alignItems: 'baseline',
fontVariantNumeric: 'tabular-nums',
});
const BackupSize = styled('div', {
color: '$gray10',
alignItems: 'center',
display: 'flex',
});
const BackupActions = styled('div', {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '0.25rem',
});
@ -127,54 +136,160 @@ interface RecoveryDialogProps {
onOpenChange: (state: boolean) => void;
}
// Returns a human-readable version of a byte size
function hrsize(bytes: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB'];
let fractBytes = bytes;
while (fractBytes >= 1024) {
fractBytes /= 1024;
units.shift();
}
return `${fractBytes.toFixed(2)} ${units[0]}`;
}
function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) {
const { t } = useTranslation();
const [backups, setBackups] = useState<main.BackupInfo[]>([]);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restored, setRestored] = useState<'idle' | 'in-progress' | 'done'>(
'idle',
);
useEffect(() => {
GetBackups().then((backupList) => {
void GetBackups().then((backupList) => {
setBackups(backupList);
console.log(backupList);
});
}, []);
const restore = async (filename: string) => {
setRestored('in-progress');
try {
await RestoreBackup(filename);
setRestoreError(null);
} catch (err) {
setRestoreError(err as string);
}
setRestored('done');
};
if (restored === 'done' && restoreError == null) {
return (
<Alert
defaultOpen={true}
open={open}
onOpenChange={(state) => {
if (onOpenChange) onOpenChange(state);
setRestored('idle');
}}
>
<AlertContent
variation="default"
title={t('pages.crash.recovery.restore-succeeded-title')}
description={t('pages.crash.recovery.restore-succeeded-body')}
actionText={t('form-actions.ok')}
onAction={() => {
if (onOpenChange) onOpenChange(false);
setRestored('idle');
}}
/>
</Alert>
);
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
if (onOpenChange) onOpenChange(state);
}}
>
<DialogContent title={'Recovery options'} closeButton={true}>
<TextBlock>
These action will irreversibly modify your database, please make sure
your database is corrupted in the first place before proceeding.
</TextBlock>
<SectionHeader>Restore from backup</SectionHeader>
<TextBlock>
Restore a previously backed up database. This will overwrite your
current database with the saved copy. Check below for the list of
saved copies.
</TextBlock>
<Scrollbar
vertical={true}
viewport={{ maxHeight: 'calc(100vh - 450px)', minHeight: '100px' }}
<>
<Alert
defaultOpen={false}
open={!!restoreError}
onOpenChange={(val: boolean) => {
if (!val) setRestoreError(null);
}}
>
<AlertContent
variation="danger"
title={t('pages.crash.recovery.restore-failed')}
description={t('pages.crash.recovery.restore-error', {
error: restoreError ?? 'unknown error',
})}
actionText={t('form-actions.ok')}
onAction={() => {
setRestoreError(null);
}}
/>
</Alert>
<Dialog
open={open}
onOpenChange={(state) => {
if (onOpenChange) onOpenChange(state);
}}
>
<DialogContent
title={t('pages.crash.recovery.title')}
closeButton={true}
>
{backups
.sort((a, b) => b.date - a.date)
.map((backup) => (
<BackupItem key={backup.filename}>
<BackupDate title={backup.filename}>
{new Date(backup.date).toLocaleString()}
</BackupDate>
<BackupActions>
<Button size="small">Restore</Button>
</BackupActions>
</BackupItem>
))}
</Scrollbar>
</DialogContent>
</Dialog>
<TextBlock>{t('pages.crash.recovery.text-head')}</TextBlock>
<SectionHeader>
{t('pages.crash.recovery.restore-head')}
</SectionHeader>
<TextBlock>{t('pages.crash.recovery.restore-desc-1')}</TextBlock>
<Scrollbar
vertical={true}
viewport={{ maxHeight: 'calc(100vh - 450px)', minHeight: '100px' }}
>
{backups
.sort((a, b) => b.date - a.date)
.map((backup) => {
const date = new Date(backup.date);
return (
<BackupItem key={backup.filename}>
<BackupDate title={backup.filename}>
{date.toLocaleDateString([], {
year: 'numeric',
month: 'short',
day: '2-digit',
})}
{' - '}
{date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
<BackupSize>{hrsize(backup.size)}</BackupSize>
</BackupDate>
<BackupActions>
<Alert>
<AlertTrigger asChild>
<Button
size="small"
disabled={restored === 'in-progress'}
>
{t('pages.crash.recovery.restore-button')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t(
'pages.crash.recovery.restore-confirm-title',
)}
description={t(
'pages.crash.recovery.restore-confirm-body',
)}
actionText={t('pages.crash.recovery.restore-button')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => {
void restore(backup.filename);
}}
/>
</Alert>
</BackupActions>
</BackupItem>
);
})}
</Scrollbar>
</DialogContent>
</Dialog>
</>
);
}
@ -191,7 +306,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
const [contactInfo, setContactInfo] = useState('');
const [submitted, setSubmitted] = useState(false);
const [code, setCode] = useState('');
const [submissionError, setSubmissionError] = useState<Error>(null);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const waiting = submitted && code.length < 1;
@ -244,7 +359,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
<AlertContent
variation="danger"
description={t('pages.crash.report.error-message', {
error: submissionError?.message ?? 'unknown server error',
error: submissionError,
})}
actionText={t('form-actions.ok')}
onAction={() => {
@ -276,7 +391,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
setCode(submissionCode);
})
.catch((err) => {
setSubmissionError(err as Error);
setSubmissionError(err as string);
});
setSubmitted(true);
}}

View file

@ -24,4 +24,6 @@ export function IsFatalError():Promise<boolean>;
export function IsServerReady():Promise<boolean>;
export function RestoreBackup(arg1:string):Promise<void>;
export function SendCrashReport(arg1:string,arg2:string):Promise<string>;

View file

@ -42,6 +42,10 @@ export function IsServerReady() {
return window['go']['main']['App']['IsServerReady']();
}
export function RestoreBackup(arg1) {
return window['go']['main']['App']['RestoreBackup'](arg1);
}
export function SendCrashReport(arg1, arg2) {
return window['go']['main']['App']['SendCrashReport'](arg1, arg2);
}

View file

@ -59,6 +59,7 @@ export namespace main {
export class BackupInfo {
filename: string;
date: number;
size: number;
static createFrom(source: any = {}) {
return new BackupInfo(source);
@ -68,6 +69,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.date = source["date"];
this.size = source["size"];
}
}
export class LogEntry {

5
go.mod
View file

@ -12,6 +12,7 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.2
github.com/json-iterator/go v1.1.12
github.com/nicklaw5/helix/v2 v2.22.0
github.com/postfinance/single v0.0.2
github.com/strimertul/kilovolt/v9 v9.1.0
github.com/strimertul/kv-pebble v1.2.1
github.com/urfave/cli/v2 v2.25.1
@ -81,7 +82,7 @@ require (
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/exp v0.0.0-20230206171751-46f607a40771 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
)

10
go.sum
View file

@ -249,6 +249,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/postfinance/single v0.0.2 h1:YLjLxxeGDnsW93oK4CxxOFSVOOiBi1OyoK4ZTl5biJw=
github.com/postfinance/single v0.0.2/go.mod h1:OYWUsdMIZK9eQyZYpAsMHN+j6+jXJ6RFUNqIWH0oC5U=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@ -437,8 +439,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@ -449,8 +451,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=