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:
parent
745824a32d
commit
4e9b2cff7d
22 changed files with 827 additions and 74 deletions
175
app.go
175
app.go
|
@ -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"
|
||||||
|
|
||||||
|
@ -25,6 +32,8 @@ type App struct {
|
||||||
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
|
||||||
|
@ -37,48 +46,77 @@ 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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': {
|
||||||
|
|
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
dc4f2d866ac751f1391a998d03e939db
|
293b48c0e126b4f88d9b5ba934df5908
|
|
@ -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>,
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -10,7 +10,7 @@ export interface ProcessedLogEntry {
|
||||||
data: object;
|
data: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processEntry({
|
export function processEntry({
|
||||||
time,
|
time,
|
||||||
caller,
|
caller,
|
||||||
level,
|
level,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
474
frontend/src/ui/ErrorWindow.tsx
Normal file
474
frontend/src/ui/ErrorWindow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
31
frontend/src/ui/components/utils/Channels.tsx
Normal file
31
frontend/src/ui/components/utils/Channels.tsx
Normal 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;
|
|
@ -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')}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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',
|
||||||
|
|
6
frontend/wailsjs/go/main/App.d.ts
vendored
6
frontend/wailsjs/go/main/App.d.ts
vendored
|
@ -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>;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
16
main.go
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue