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

feat: Error page (wip recovery option)

This commit is contained in:
Ash Keel 2023-04-14 20:04:21 +02:00
parent 745824a32d
commit 4e9b2cff7d
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
22 changed files with 827 additions and 74 deletions

187
app.go
View file

@ -1,7 +1,14 @@
package main package main
import ( import (
"bytes"
"context" "context"
"errors"
"fmt"
"io"
"mime/multipart"
nethttp "net/http"
"os"
"runtime/debug" "runtime/debug"
"strconv" "strconv"
@ -21,10 +28,12 @@ import (
// App struct // App struct
type App struct { type App struct {
ctx context.Context ctx context.Context
cliParams *cli.Context cliParams *cli.Context
driver database.DatabaseDriver driver database.DatabaseDriver
ready *sync.RWSync[bool] ready *sync.RWSync[bool]
isFatalError *sync.RWSync[bool]
backupOptions database.BackupOptions
db *database.LocalDBClient db *database.LocalDBClient
twitchManager *twitch.Manager twitchManager *twitch.Manager
@ -35,50 +44,79 @@ type App struct {
// NewApp creates a new App application struct // NewApp creates a new App application struct
func NewApp(cliParams *cli.Context) *App { func NewApp(cliParams *cli.Context) *App {
return &App{ return &App{
cliParams: cliParams, cliParams: cliParams,
ready: sync.NewRWSync(false), ready: sync.NewRWSync(false),
isFatalError: sync.NewRWSync(false),
} }
} }
// startup is called when the app starts // startup is called when the app starts
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
a.stop(ctx)
_ = logger.Sync()
switch v := r.(type) {
case error:
a.showFatalError(v, v.Error())
default:
a.showFatalError(errors.New(fmt.Sprint(v)), "Runtime error encountered")
}
}
}()
a.ctx = ctx a.ctx = ctx
a.backupOptions = database.BackupOptions{
// Make KV hub
var err error
a.driver, err = database.GetDatabaseDriver(a.cliParams)
failOnError(err, "error opening database")
// Start database backup task
backupOpts := database.BackupOptions{
BackupDir: a.cliParams.String("backup-dir"), BackupDir: a.cliParams.String("backup-dir"),
BackupInterval: a.cliParams.Int("backup-interval"), BackupInterval: a.cliParams.Int("backup-interval"),
MaxBackups: a.cliParams.Int("max-backups"), MaxBackups: a.cliParams.Int("max-backups"),
} }
if backupOpts.BackupInterval > 0 {
go BackupTask(a.driver, backupOpts) // Make KV hub
var err error
a.driver, err = database.GetDatabaseDriver(a.cliParams)
if err != nil {
a.showFatalError(err, "Error opening database")
return
}
// Start database backup task
if a.backupOptions.BackupInterval > 0 {
go BackupTask(a.driver, a.backupOptions)
} }
hub := a.driver.Hub() hub := a.driver.Hub()
go hub.Run() go hub.Run()
a.db, err = database.NewLocalClient(hub, logger) a.db, err = database.NewLocalClient(hub, logger)
failOnError(err, "failed to initialize database module") if err != nil {
a.showFatalError(err, "Failed to initialize database module")
return
}
// Set meta keys // Set meta keys
_ = a.db.PutKey("stul-meta/version", appVersion) _ = a.db.PutKey("stul-meta/version", appVersion)
// Create logger and endpoints // Create logger and endpoints
a.httpServer, err = http.NewServer(a.db, logger) a.httpServer, err = http.NewServer(a.db, logger)
failOnError(err, "could not initialize http server") if err != nil {
a.showFatalError(err, "Could not initialize http server")
return
}
// Create twitch client // Create twitch client
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger) a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
failOnError(err, "could not initialize twitch client") if err != nil {
a.showFatalError(err, "Could not initialize twitch client")
return
}
// Initialize loyalty system // Initialize loyalty system
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger) a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
failOnError(err, "could not initialize loyalty manager") if err != nil {
a.showFatalError(err, "Could not initialize loyalty manager")
return
}
a.ready.Set(true) a.ready.Set(true)
runtime.EventsEmit(ctx, "ready", true) runtime.EventsEmit(ctx, "ready", true)
@ -92,7 +130,10 @@ func (a *App) startup(ctx context.Context) {
}() }()
// Run HTTP server // Run HTTP server
failOnError(a.httpServer.Listen(), "HTTP server stopped") if err := a.httpServer.Listen(); err != nil {
a.showFatalError(err, "HTTP server stopped")
return
}
} }
func (a *App) stop(context.Context) { func (a *App) stop(context.Context) {
@ -122,6 +163,10 @@ func (a *App) IsServerReady() bool {
return a.ready.Get() return a.ready.Get()
} }
func (a *App) IsFatalError() bool {
return a.isFatalError.Get()
}
func (a *App) GetKilovoltBind() string { func (a *App) GetKilovoltBind() string {
if a.httpServer == nil { if a.httpServer == nil {
return "" return ""
@ -145,6 +190,78 @@ func (a *App) GetDocumentation() map[string]docs.KeyObject {
return docs.Keys return docs.Keys
} }
func (a *App) SendCrashReport(errorData string, info string) (string, error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
// Add text fields
if err := w.WriteField("error", errorData); err != nil {
logger.Error("could not encode field error for crash report", zap.Error(err))
}
if len(info) > 0 {
if err := w.WriteField("info", info); err != nil {
logger.Error("could not encode field info for crash report", zap.Error(err))
}
}
// Add log files
_ = logger.Sync()
addFile(w, "log", logFilename)
addFile(w, "paniclog", panicFilename)
if err := w.Close(); err != nil {
logger.Error("could not prepare request for crash report", zap.Error(err))
return "", err
}
resp, err := nethttp.Post(crashReportURL, w.FormDataContentType(), &b)
if err != nil {
logger.Error("could not send crash report", zap.Error(err))
return "", err
}
// Check the response
if resp.StatusCode != nethttp.StatusOK {
byt, _ := io.ReadAll(resp.Body)
logger.Error("crash report server returned error", zap.String("status", resp.Status), zap.String("response", string(byt)))
return "", fmt.Errorf("crash report server returned error: %s - %s", resp.Status, string(byt))
}
byt, err := io.ReadAll(resp.Body)
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 { type VersionInfo struct {
Release string `json:"release"` Release string `json:"release"`
BuildInfo *debug.BuildInfo `json:"build"` BuildInfo *debug.BuildInfo `json:"build"`
@ -157,3 +274,31 @@ func (a *App) GetAppVersion() VersionInfo {
BuildInfo: info, BuildInfo: info,
} }
} }
func (a *App) showFatalError(err error, text string, fields ...zap.Field) {
if err != nil {
fields = append(fields, zap.Error(err))
fields = append(fields, zap.String("Z", string(debug.Stack())))
logger.Error(text, fields...)
runtime.EventsEmit(a.ctx, "fatalError")
a.isFatalError.Set(true)
}
}
func addFile(m *multipart.Writer, field string, filename string) {
logfile, err := m.CreateFormFile(field, filename)
if err != nil {
logger.Error("could not encode field log for crash report", zap.Error(err))
return
}
file, err := os.Open(filename)
if err != nil {
logger.Error("could not open file for including in crash report", zap.Error(err), zap.String("file", filename))
return
}
if _, err = io.Copy(logfile, file); err != nil {
logger.Error("could not read from file for including in crash report", zap.Error(err), zap.String("file", filename))
}
}

View file

@ -50,6 +50,9 @@ func (p *PebbleDatabase) Hub() *kv.Hub {
} }
func (p *PebbleDatabase) Close() error { func (p *PebbleDatabase) Close() error {
if p.hub != nil {
p.hub.Close()
}
err := p.db.Close() err := p.db.Close()
if err != nil { if err != nil {
return fmt.Errorf("could not close database: %w", err) return fmt.Errorf("could not close database: %w", err)

View file

@ -1,6 +1,6 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'import'], plugins: ['react-refresh', '@typescript-eslint', 'import'],
extends: [ extends: [
'airbnb-base', 'airbnb-base',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
@ -28,6 +28,7 @@ module.exports = {
'@typescript-eslint/no-unsafe-return': ['error'], '@typescript-eslint/no-unsafe-return': ['error'],
'@typescript-eslint/switch-exhaustiveness-check': ['error'], '@typescript-eslint/switch-exhaustiveness-check': ['error'],
'@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
'react-refresh/only-export-components': 'warn'
}, },
settings: { settings: {
'import/resolver': { 'import/resolver': {

View file

@ -55,6 +55,7 @@
"eslint-import-resolver-typescript": "^3.5.4", "eslint-import-resolver-typescript": "^3.5.4",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react-refresh": "^0.3.4",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"rimraf": "^4.4.1" "rimraf": "^4.4.1"
} }
@ -2818,6 +2819,15 @@
} }
} }
}, },
"node_modules/eslint-plugin-react-refresh": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.3.4.tgz",
"integrity": "sha512-E0ViBglxSQAERBp6eTj5fPgtCRtDonnbCFiVQBhf4Dto2blJRxg1dFUMdMh7N6ljTI4UwPhHwYDQ3Dyo4m6bwA==",
"dev": true,
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@ -7263,6 +7273,13 @@
"prettier-linter-helpers": "^1.0.0" "prettier-linter-helpers": "^1.0.0"
} }
}, },
"eslint-plugin-react-refresh": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.3.4.tgz",
"integrity": "sha512-E0ViBglxSQAERBp6eTj5fPgtCRtDonnbCFiVQBhf4Dto2blJRxg1dFUMdMh7N6ljTI4UwPhHwYDQ3Dyo4m6bwA==",
"dev": true,
"requires": {}
},
"eslint-scope": { "eslint-scope": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",

View file

@ -58,6 +58,7 @@
"eslint-import-resolver-typescript": "^3.5.4", "eslint-import-resolver-typescript": "^3.5.4",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react-refresh": "^0.3.4",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"rimraf": "^4.4.1" "rimraf": "^4.4.1"
} }

View file

@ -1 +1 @@
dc4f2d866ac751f1391a998d03e939db 293b48c0e126b4f88d9b5ba934df5908

View file

@ -1,27 +1,47 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import { StrictMode } from 'react'; import { StrictMode, useEffect, useState } from 'react';
import { EventsOff, EventsOn } from '@wailsapp/runtime';
import { IsFatalError } from '@wailsapp/go/main/App';
import 'inter-ui/inter.css'; import 'inter-ui/inter.css';
import '@fontsource/space-mono/index.css'; import '@fontsource/space-mono/index.css';
import 'normalize.css/normalize.css'; import 'normalize.css/normalize.css';
import './locale/setup'; import './locale/setup';
import store from './store'; import store from './store';
import App from './ui/App'; import App from './ui/App';
import ErrorWindow from './ui/ErrorWindow';
import { globalStyles } from './ui/theme'; import { globalStyles } from './ui/theme';
globalStyles(); globalStyles();
function AppWrapper() {
const [fatalErrorEncountered, setFatalErrorStatus] = useState(false);
useEffect(() => {
void IsFatalError().then(setFatalErrorStatus);
EventsOn('fatalError', () => {
setFatalErrorStatus(true);
});
return () => {
EventsOff('fatalError');
};
}, []);
if (fatalErrorEncountered) {
return <ErrorWindow />;
}
return <App />;
}
const main = document.getElementById('main'); const main = document.getElementById('main');
const root = createRoot(main); const root = createRoot(main);
root.render( root.render(
<Provider store={store}> <Provider store={store}>
<HashRouter> <HashRouter>
<StrictMode> <StrictMode>
<App /> <AppWrapper />
</StrictMode> </StrictMode>
</HashRouter> </HashRouter>
</Provider>, </Provider>,

View file

@ -339,6 +339,31 @@
"error-alert": "Error details for {{name}}", "error-alert": "Error details for {{name}}",
"incompatible-body": "This extension requires {{APPNAME}} version {{version}} and up, you are currently running {{appversion}}, which may be too old and miss required features", "incompatible-body": "This extension requires {{APPNAME}} version {{version}} and up, you are currently running {{appversion}}, which may be too old and miss required features",
"incompatible-warning": "This extension is not compatible" "incompatible-warning": "This extension is not compatible"
},
"crash": {
"fatal-message": "A fatal error has occurred and strimertül has stopped working, check the details below:",
"action-header": "What to do?",
"action-submit-line": "Consider submitting this report using the Report button below so someone can look at it.",
"action-recover-line": "If this error happens every time you start the app or if you've been instructed to, click the Recovery button to restore the database to an earlier backup.",
"action-log-line": "Logs and other crash related info can be found in the log files <m>{{A}}</m> and <m>{{B}}</m>.",
"button-report": "Report this error",
"button-recovery": "Recovery options",
"app-log-header": "Application logs for this run",
"report": {
"button-send": "Send report",
"dialog-title": "Report this error",
"thanks-line": "Thanks for choosing to submit this error! If you want, please write below what you were trying to do or anything else that you think might help.",
"additional-label": "Additional info (optional)",
"text-placeholder": "What were you doing before this happened?",
"email-label": "Include email address (if you want to be contacted about this)",
"email-placeholder": "Write your email address here",
"transparency-line": "When clicking \"Send report\", the following info will be collected and sent:",
"transparency-files": "The contents of <m>{{A}}</m> and <m>{{B}}</m>",
"transparency-info": "Information about the error that triggered this crash",
"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."
}
} }
}, },
"form-actions": { "form-actions": {

View file

@ -392,6 +392,29 @@
"error-alert": "Dettagli errore per {{name}}", "error-alert": "Dettagli errore per {{name}}",
"incompatible-body": "Questa estensione richiede {{APPNAME}} versione {{version}} o successive, al momento stai utilizzando {{appversion}} che potrebbe essere troppo vecchia e perciò senza alcune funzionalità richieste", "incompatible-body": "Questa estensione richiede {{APPNAME}} versione {{version}} o successive, al momento stai utilizzando {{appversion}} che potrebbe essere troppo vecchia e perciò senza alcune funzionalità richieste",
"incompatible-warning": "Questa estensione non è compatibile" "incompatible-warning": "Questa estensione non è compatibile"
},
"crash": {
"action-header": "Che fare?",
"action-recover-line": "Se questo errore si verifica ogni volta che avvii l'app o se ti è stato richiesto di farlo, fai clic sul pulsante Ripristino per recuperare il database da un backup precedente.",
"action-log-line": "I log e altre informazioni relative agli arresti anomali sono disponibili nei file di log <m>{{A}}</m> e <m>{{B}}</m>.",
"action-submit-line": "Puoi inviare una segnalazione di questo arresto anomalo utilizzando il pulsante Segnala in basso in modo che qualcuno possa esaminarla.",
"app-log-header": "Log applicazione per questa esecuzione",
"button-recovery": "Menù ripristino",
"button-report": "Segnala questo errore",
"fatal-message": "Si è verificato un errore irreversibile e strimertül ha smesso di funzionare, ecco i dettagli:",
"report": {
"additional-label": "Info aggiuntive (opzionale)",
"button-send": "Invia segnalazione",
"dialog-title": "Segnala questo errore",
"text-placeholder": "Cosa stavi facendo nell'applicazione?",
"thanks-line": "Grazie per aver scelto di segnalare questo errore! \nSe vuoi, scrivi qui sotto cosa stavi cercando di fare o qualsiasi altra cosa che pensi possa essere d'aiuto.",
"email-label": "Includi una email (se vuoi essere contattato in merito)",
"email-placeholder": "Scrivi qui il tuo indirizzo email",
"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"
}
} }
}, },
"time": { "time": {

View file

@ -10,7 +10,7 @@ export interface ProcessedLogEntry {
data: object; data: object;
} }
function processEntry({ export function processEntry({
time, time,
caller, caller,
level, level,

View file

@ -11,7 +11,7 @@ import {
} from '@radix-ui/react-icons'; } from '@radix-ui/react-icons';
import { EventsOff, EventsOn } from '@wailsapp/runtime/runtime'; import { EventsOff, EventsOn } from '@wailsapp/runtime/runtime';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { import {

View file

@ -0,0 +1,474 @@
import { CheckIcon } from '@radix-ui/react-icons';
import {
GetBackups,
GetLastLogs,
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 { Trans, useTranslation } from 'react-i18next';
import { languages } from '~/locale/languages';
import { ProcessedLogEntry, processEntry } from '~/store/logging/reducer';
import DialogContent from '~/ui/components/DialogContent';
import { LogItem } from '~/ui/components/LogViewer';
import Scrollbar from '~/ui/components/utils/Scrollbar';
import {
Button,
Checkbox,
CheckboxIndicator,
Dialog,
DialogActions,
Field,
FlexRow,
InputBox,
Label,
MultiToggle,
MultiToggleItem,
PageContainer,
PageHeader,
SectionHeader,
styled,
Textarea,
TextBlock,
} from '~/ui/theme';
import AlertContent from './components/AlertContent';
import { Alert, AlertDescription } from './theme/alert';
const Container = styled('div', {
position: 'relative',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
height: '100vh',
border: '2px solid $red10',
});
const ErrorHeader = styled('h1', {
color: '$red10',
textTransform: 'capitalize',
});
const ErrorDetails = styled('dl', {
display: 'grid',
gridTemplateColumns: '100px 1fr',
margin: '0',
});
const ErrorDetailKey = styled('dt', {
fontWeight: 'bold',
textTransform: 'capitalize',
gridColumn: '1',
});
const ErrorDetailValue = styled('dd', {
padding: '0',
margin: '0',
marginBottom: '0.5rem',
gridColumn: '2',
});
const LogContainer = styled('div', {
display: 'flex',
flexDirection: 'column',
gap: '3px',
});
const Mono = styled('code', {
background: '$gray5',
padding: '3px 5px',
borderRadius: '3px',
whiteSpace: 'nowrap',
});
const MiniHeader = styled(SectionHeader, {
fontSize: '14pt',
});
const LanguageSelector = styled('div', {
top: '10px',
right: '10px',
display: 'flex',
gap: '1rem',
position: 'absolute',
zIndex: '1',
});
const LanguageItem = styled(MultiToggleItem, {
fontSize: '8pt',
padding: '5px 6px 4px',
textTransform: 'uppercase',
});
const BackupItem = styled('article', {
marginBottom: '0.4rem',
backgroundColor: '$gray2',
margin: '0.5rem 0',
padding: '0.3rem 0.5rem',
borderLeft: '5px solid $teal8',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
transition: 'all 50ms',
display: 'flex',
});
const BackupDate = styled('div', {
flex: '1',
display: 'flex',
gap: '0.5rem',
alignItems: 'baseline',
});
const BackupActions = styled('div', {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
});
interface RecoveryDialogProps {
open: boolean;
onOpenChange: (state: boolean) => void;
}
function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) {
const { t } = useTranslation();
const [backups, setBackups] = useState<main.BackupInfo[]>([]);
useEffect(() => {
GetBackups().then((backupList) => {
setBackups(backupList);
console.log(backupList);
});
}, []);
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' }}
>
{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>
);
}
interface ReportDialogProps {
open: boolean;
onOpenChange: (state: boolean) => void;
errorData?: ProcessedLogEntry;
}
function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
const { t } = useTranslation();
const [errorDesc, setErrorDesc] = useState('');
const [contactEnabled, setContactEnabled] = useState(false);
const [contactInfo, setContactInfo] = useState('');
const [submitted, setSubmitted] = useState(false);
const [code, setCode] = useState('');
const [submissionError, setSubmissionError] = useState<Error>(null);
const waiting = submitted && code.length < 1;
if (code) {
return (
<Alert
open={open}
onOpenChange={(state) => {
if (onOpenChange) onOpenChange(state);
}}
>
<AlertContent
actionText={t('form-actions.ok')}
onAction={() => {
setSubmissionError(null);
}}
>
<AlertDescription variation="default">
<Trans
t={t}
i18nKey="pages.crash.report.post-report"
values={{ code }}
components={{
m: (
<Mono
css={{
display: 'block',
margin: '10px',
textAlign: 'center',
}}
/>
),
}}
/>
</AlertDescription>
</AlertContent>
</Alert>
);
}
return (
<>
<Alert
defaultOpen={false}
open={!!submissionError}
onOpenChange={(val: boolean) => {
if (!val) setSubmissionError(null);
}}
>
<AlertContent
variation="danger"
description={t('pages.crash.report.error-message', {
error: submissionError?.message ?? 'unknown server error',
})}
actionText={t('form-actions.ok')}
onAction={() => {
setSubmissionError(null);
}}
/>
</Alert>
<Dialog
open={open}
onOpenChange={(state) => {
if (onOpenChange) onOpenChange(state);
}}
>
<DialogContent
title={t('pages.crash.report.dialog-title')}
closeButton={!waiting}
>
<form
style={waiting ? { opacity: 0.7 } : {}}
onSubmit={(e) => {
e.preventDefault();
console.log('test');
let desc = errorDesc;
if (contactEnabled && contactInfo) {
desc += `\n\nEmail contact: ${contactInfo}`;
}
SendCrashReport(JSON.stringify(errorData), desc)
.then((submissionCode) => {
setCode(submissionCode);
})
.catch((err) => {
setSubmissionError(err as Error);
});
setSubmitted(true);
}}
>
<TextBlock css={{ fontSize: '0.9em' }}>
{t('pages.crash.report.thanks-line')}
</TextBlock>
<TextBlock css={{ fontSize: '0.9em' }}>
{t('pages.crash.report.transparency-line')}
<ul>
<li>
<Trans
t={t}
i18nKey="pages.crash.report.transparency-files"
values={{
A: 'strimertul.log',
B: 'strimertul-panic.log',
}}
components={{
m: <Mono />,
}}
/>
</li>
<li>{t('pages.crash.report.transparency-info')}</li>
<li>{t('pages.crash.report.transparency-user')}</li>
</ul>
</TextBlock>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="error-desc">
{t('pages.crash.report.additional-label')}
</Label>
<Textarea
id="error-desc"
rows={5}
value={errorDesc ?? ''}
onChange={(e) => {
setErrorDesc(e.target.value);
}}
placeholder={t('pages.crash.report.text-placeholder')}
>
{errorDesc ?? ''}
</Textarea>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="error-contact">
<FlexRow align="left" spacing={1}>
<Checkbox
checked={contactEnabled}
onCheckedChange={(ev) => {
setContactEnabled(!!ev);
}}
>
<CheckboxIndicator>
{contactEnabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
{t('pages.crash.report.email-label')}
</FlexRow>
</Label>
<InputBox
type="email"
id="error-contact"
placeholder={
contactEnabled
? t('pages.crash.report.email-placeholder')
: ''
}
value={contactInfo ?? ''}
required={contactEnabled}
disabled={!contactEnabled}
onChange={(e) => setContactInfo(e.target.value)}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit" disabled={waiting}>
{t('pages.crash.report.button-send')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
</>
);
}
export default function ErrorWindow(): JSX.Element {
const [t, i18n] = useTranslation();
const [logs, setLogs] = useState<ProcessedLogEntry[]>([]);
const [reportDialogOpen, setReportDialogOpen] = useState(false);
const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false);
useEffect(() => {
void GetLastLogs().then((appLogs) => {
setLogs(appLogs.map(processEntry).reverse());
});
EventsOn('log-event', (event: main.LogEntry) => {
setLogs([processEntry(event), ...logs]);
});
return () => {
EventsOff('log-event');
};
}, []);
const fatal = logs.find((log) => log.level === 'error');
return (
<Container>
<ReportDialog
open={reportDialogOpen}
onOpenChange={setReportDialogOpen}
errorData={fatal}
/>
<RecoveryDialog
open={recoveryDialogOpen}
onOpenChange={setRecoveryDialogOpen}
/>
<LanguageSelector>
<MultiToggle
value={i18n.resolvedLanguage}
type="single"
onValueChange={(newLang) => {
void i18n.changeLanguage(newLang);
}}
>
{languages.map((lang) => (
<LanguageItem
key={lang.code}
aria-label={lang.name}
value={lang.code}
title={lang.name}
>
{lang.code}
</LanguageItem>
))}
</MultiToggle>
</LanguageSelector>
<PageContainer>
<Scrollbar vertical={true} viewport={{ maxHeight: '100vh' }}>
<PageHeader>
<TextBlock>{t('pages.crash.fatal-message')}</TextBlock>
</PageHeader>
{fatal ? (
<>
<ErrorHeader>{fatal.message}</ErrorHeader>
<ErrorDetails>
{Object.keys(fatal.data)
.filter((key) => key.length > 1)
.map((key) => (
<Fragment key={key}>
<ErrorDetailKey>{key}</ErrorDetailKey>
<ErrorDetailValue>{fatal.data[key]}</ErrorDetailValue>
</Fragment>
))}
</ErrorDetails>
</>
) : null}
<SectionHeader>{t('pages.crash.action-header')}</SectionHeader>
<TextBlock>{t('pages.crash.action-submit-line')}</TextBlock>
<TextBlock>{t('pages.crash.action-recover-line')}</TextBlock>
<TextBlock>
<Trans
t={t}
i18nKey="pages.crash.action-log-line"
values={{
A: 'strimertul.log',
B: 'strimertul-panic.log',
}}
components={{
m: <Mono />,
}}
/>
</TextBlock>
<FlexRow align="left" spacing={1} css={{ paddingTop: '0.5rem' }}>
<Button
variation={'danger'}
onClick={() => setReportDialogOpen(true)}
>
{t('pages.crash.button-report')}
</Button>
<Button onClick={() => setRecoveryDialogOpen(true)}>
{t('pages.crash.button-recovery')}
</Button>
</FlexRow>
<MiniHeader>{t('pages.crash.app-log-header')}</MiniHeader>
<LogContainer>
{logs.map((log) => (
<LogItem key={log.time.toString()} data={log} />
))}
</LogContainer>
</Scrollbar>
</PageContainer>
</Container>
);
}

View file

@ -108,6 +108,7 @@ const LevelToggle = styled(MultiToggleItem, {
interface LogItemProps { interface LogItemProps {
data: ProcessedLogEntry; data: ProcessedLogEntry;
expandDefault?: boolean;
} }
const LogEntryContainer = styled('div', { const LogEntryContainer = styled('div', {
@ -231,12 +232,12 @@ const LogDetailKey = styled('div', {
}); });
const LogDetailValue = styled('div', { flex: '1' }); const LogDetailValue = styled('div', { flex: '1' });
function LogItem({ data }: LogItemProps) { export function LogItem({ data, expandDefault }: LogItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const levelStyle = isSupportedLevel(data.level) ? data.level : null; const levelStyle = isSupportedLevel(data.level) ? data.level : null;
const details = Object.entries(data.data).filter(([key]) => key.length > 1); const details = Object.entries(data.data).filter(([key]) => key.length > 1);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(expandDefault ?? false);
const copyToClipboard = async () => { const copyToClipboard = async () => {
await navigator.clipboard.writeText(JSON.stringify(data.data)); await navigator.clipboard.writeText(JSON.stringify(data.data));
setCopied(true); setCopied(true);

View file

@ -0,0 +1,31 @@
import {
DiscordLogoIcon,
EnvelopeClosedIcon,
GitHubLogoIcon,
} from '@radix-ui/react-icons';
import { ChannelList, Channel, ChannelLink } from '../../pages/Strimertul';
export const Channels = (
<ChannelList>
<Channel>
<ChannelLink href="https://github.com/strimertul/strimertul/issues">
<GitHubLogoIcon width={24} height={24} />
github.com/strimertul/strimertul/issues
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="https://nebula.cafe/discord">
<DiscordLogoIcon width={24} height={24} />
nebula.cafe/discord
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="mailto:strimertul@nebula.cafe">
<EnvelopeClosedIcon width={24} height={24} />
strimertul@nebula.cafe
</ChannelLink>
</Channel>
</ChannelList>
);
export default Channels;

View file

@ -23,6 +23,7 @@ import AlertContent from '../components/AlertContent';
import BrowserLink from '../components/BrowserLink'; import BrowserLink from '../components/BrowserLink';
import DefinitionTable from '../components/DefinitionTable'; import DefinitionTable from '../components/DefinitionTable';
import RevealLink from '../components/utils/RevealLink'; import RevealLink from '../components/utils/RevealLink';
import Channels from '../components/utils/Channels';
import { import {
Button, Button,
@ -39,7 +40,6 @@ import {
TextBlock, TextBlock,
} from '../theme'; } from '../theme';
import { Alert } from '../theme/alert'; import { Alert } from '../theme/alert';
import { channels } from './Strimertul';
const Container = styled('div', { const Container = styled('div', {
display: 'flex', display: 'flex',
@ -582,7 +582,7 @@ function DoneStep() {
<SectionHeader>{t('pages.onboarding.done-header')}</SectionHeader> <SectionHeader>{t('pages.onboarding.done-header')}</SectionHeader>
<TextBlock>{t('pages.onboarding.done-p1')}</TextBlock> <TextBlock>{t('pages.onboarding.done-p1')}</TextBlock>
<TextBlock>{t('pages.onboarding.done-p2')}</TextBlock> <TextBlock>{t('pages.onboarding.done-p2')}</TextBlock>
{channels} {Channels}
<TextBlock>{t('pages.onboarding.done-p3')}</TextBlock> <TextBlock>{t('pages.onboarding.done-p3')}</TextBlock>
<Button variation={'primary'} onClick={() => done()}> <Button variation={'primary'} onClick={() => done()}>
{t('pages.onboarding.done-button')} {t('pages.onboarding.done-button')}

View file

@ -1,11 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { keyframes } from '@stitches/react'; import { keyframes } from '@stitches/react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import {
DiscordLogoIcon,
EnvelopeClosedIcon,
GitHubLogoIcon,
} from '@radix-ui/react-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
// @ts-expect-error Asset import // @ts-expect-error Asset import
@ -13,6 +8,7 @@ import logo from '~/assets/icon-logo.svg';
import { APPNAME, PageContainer, PageHeader, styled } from '../theme'; import { APPNAME, PageContainer, PageHeader, styled } from '../theme';
import BrowserLink from '../components/BrowserLink'; import BrowserLink from '../components/BrowserLink';
import Channels from '../components/utils/Channels';
const gradientAnimation = keyframes({ const gradientAnimation = keyframes({
'0%': { '0%': {
@ -66,12 +62,16 @@ const SectionParagraph = styled('p', {
lineHeight: '1.5', lineHeight: '1.5',
paddingBottom: '1rem', paddingBottom: '1rem',
}); });
const ChannelList = styled('ul', { listStyle: 'none', padding: 0, margin: 0 }); export const ChannelList = styled('ul', {
const Channel = styled('li', { listStyle: 'none',
padding: 0,
margin: 0,
});
export const Channel = styled('li', {
marginBottom: '1rem', marginBottom: '1rem',
fontSize: '1rem', fontSize: '1rem',
}); });
const ChannelLink = styled(BrowserLink, { export const ChannelLink = styled(BrowserLink, {
textDecoration: 'none', textDecoration: 'none',
color: '$teal11', color: '$teal11',
display: 'inline-flex', display: 'inline-flex',
@ -83,29 +83,6 @@ const ChannelLink = styled(BrowserLink, {
}, },
}); });
export const channels = (
<ChannelList>
<Channel>
<ChannelLink href="https://github.com/strimertul/strimertul/issues">
<GitHubLogoIcon width={24} height={24} />
github.com/strimertul/strimertul/issues
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="https://nebula.cafe/discord">
<DiscordLogoIcon width={24} height={24} />
nebula.cafe/discord
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="mailto:strimertul@nebula.cafe">
<EnvelopeClosedIcon width={24} height={24} />
strimertul@nebula.cafe
</ChannelLink>
</Channel>
</ChannelList>
);
export default function StrimertulPage(): React.ReactElement { export default function StrimertulPage(): React.ReactElement {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
@ -145,7 +122,7 @@ export default function StrimertulPage(): React.ReactElement {
<SectionParagraph css={{ paddingBottom: 0 }}> <SectionParagraph css={{ paddingBottom: 0 }}>
{t('pages.strimertul.need-help-p1')} {t('pages.strimertul.need-help-p1')}
</SectionParagraph> </SectionParagraph>
{channels} {Channels}
</Section> </Section>
<Section> <Section>
<SectionHeader>{t('pages.strimertul.credits-header')}</SectionHeader> <SectionHeader>{t('pages.strimertul.credits-header')}</SectionHeader>

View file

@ -53,6 +53,7 @@ const inputStyles = {
padding: '0.5rem', padding: '0.5rem',
borderRadius: theme.borderRadius.form, borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2', backgroundColor: '$gray2',
transition: 'all 80ms',
'&:hover': { '&:hover': {
borderColor: '$teal7', borderColor: '$teal7',
}, },
@ -88,6 +89,7 @@ export const Textarea = styled('textarea', {
padding: '0.5rem', padding: '0.5rem',
borderRadius: theme.borderRadius.form, borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2', backgroundColor: '$gray2',
transition: 'all 80ms',
'&:hover': { '&:hover': {
borderColor: '$teal7', borderColor: '$teal7',
}, },
@ -332,7 +334,8 @@ export const Checkbox = styled(CheckboxPrimitive.Root, {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
border: '1px solid $gray6', border: '1px solid $gray6',
backgroundColor: '$gray4', backgroundColor: '$gray3',
transition: 'all 60ms',
'&:hover': { '&:hover': {
borderColor: '$teal6', borderColor: '$teal6',
backgroundColor: '$gray5', backgroundColor: '$gray5',

View file

@ -8,6 +8,8 @@ export function AuthenticateKVClient(arg1:string):Promise<void>;
export function GetAppVersion():Promise<main.VersionInfo>; export function GetAppVersion():Promise<main.VersionInfo>;
export function GetBackups():Promise<Array<main.BackupInfo>>;
export function GetDocumentation():Promise<{[key: string]: docs.KeyObject}>; export function GetDocumentation():Promise<{[key: string]: docs.KeyObject}>;
export function GetKilovoltBind():Promise<string>; export function GetKilovoltBind():Promise<string>;
@ -18,4 +20,8 @@ export function GetTwitchAuthURL():Promise<string>;
export function GetTwitchLoggedUser():Promise<helix.User>; export function GetTwitchLoggedUser():Promise<helix.User>;
export function IsFatalError():Promise<boolean>;
export function IsServerReady():Promise<boolean>; export function IsServerReady():Promise<boolean>;
export function SendCrashReport(arg1:string,arg2:string):Promise<string>;

View file

@ -10,6 +10,10 @@ export function GetAppVersion() {
return window['go']['main']['App']['GetAppVersion'](); return window['go']['main']['App']['GetAppVersion']();
} }
export function GetBackups() {
return window['go']['main']['App']['GetBackups']();
}
export function GetDocumentation() { export function GetDocumentation() {
return window['go']['main']['App']['GetDocumentation'](); return window['go']['main']['App']['GetDocumentation']();
} }
@ -30,6 +34,14 @@ export function GetTwitchLoggedUser() {
return window['go']['main']['App']['GetTwitchLoggedUser'](); return window['go']['main']['App']['GetTwitchLoggedUser']();
} }
export function IsFatalError() {
return window['go']['main']['App']['IsFatalError']();
}
export function IsServerReady() { export function IsServerReady() {
return window['go']['main']['App']['IsServerReady'](); return window['go']['main']['App']['IsServerReady']();
} }
export function SendCrashReport(arg1, arg2) {
return window['go']['main']['App']['SendCrashReport'](arg1, arg2);
}

View file

@ -56,6 +56,20 @@ export namespace helix {
export namespace main { export namespace main {
export class BackupInfo {
filename: string;
date: number;
static createFrom(source: any = {}) {
return new BackupInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.date = source["date"];
}
}
export class LogEntry { export class LogEntry {
caller: string; caller: string;
time: string; time: string;

View file

@ -31,7 +31,7 @@ func initLogger(level zapcore.Level) {
fileLogger := zapcore.NewCore( fileLogger := zapcore.NewCore(
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
zapcore.AddSync(&lumberjack.Logger{ zapcore.AddSync(&lumberjack.Logger{
Filename: "strimertul.log", Filename: logFilename,
MaxSize: 500, MaxSize: 500,
MaxBackups: 3, MaxBackups: 3,
MaxAge: 28, MaxAge: 28,

16
main.go
View file

@ -25,6 +25,12 @@ var json = jsoniter.ConfigFastest
var appVersion = "v0.0.0-UNKNOWN" var appVersion = "v0.0.0-UNKNOWN"
const (
crashReportURL = "https://crash.strimertul.stream/upload"
logFilename = "strimertul.log"
panicFilename = "strimertul-panic.log"
)
//go:embed frontend/dist //go:embed frontend/dist
var frontend embed.FS var frontend embed.FS
@ -85,9 +91,10 @@ func main() {
level = zapcore.InfoLevel level = zapcore.InfoLevel
} }
initLogger(level) initLogger(level)
logger.Info("app started", zap.String("version", appVersion))
// Create file for panics // Create file for panics
panicLog, err = os.OpenFile("strimertul-panic.log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o666) panicLog, err = os.OpenFile(panicFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o666)
if err != nil { if err != nil {
logger.Warn("could not create panic log", zap.Error(err)) logger.Warn("could not create panic log", zap.Error(err))
} else { } else {
@ -158,13 +165,6 @@ func warnOnError(err error, text string, fields ...zap.Field) {
} }
} }
func failOnError(err error, text string, fields ...zap.Field) {
if err != nil {
fields = append(fields, zap.Error(err))
logger.Fatal(text, fields...)
}
}
func fatalError(err error, text string) error { func fatalError(err error, text string) error {
return cli.Exit(fmt.Errorf("%s: %w", text, err), 1) return cli.Exit(fmt.Errorf("%s: %w", text, err), 1)
} }