diff --git a/app.go b/app.go index 8d4da2b..29be1a4 100644 --- a/app.go +++ b/app.go @@ -1,7 +1,14 @@ package main import ( + "bytes" "context" + "errors" + "fmt" + "io" + "mime/multipart" + nethttp "net/http" + "os" "runtime/debug" "strconv" @@ -21,10 +28,12 @@ import ( // App struct type App struct { - ctx context.Context - cliParams *cli.Context - driver database.DatabaseDriver - ready *sync.RWSync[bool] + ctx context.Context + cliParams *cli.Context + driver database.DatabaseDriver + ready *sync.RWSync[bool] + isFatalError *sync.RWSync[bool] + backupOptions database.BackupOptions db *database.LocalDBClient twitchManager *twitch.Manager @@ -35,50 +44,79 @@ type App struct { // NewApp creates a new App application struct func NewApp(cliParams *cli.Context) *App { return &App{ - cliParams: cliParams, - ready: sync.NewRWSync(false), + cliParams: cliParams, + ready: sync.NewRWSync(false), + isFatalError: sync.NewRWSync(false), } } // startup is called when the app starts 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 - - // 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{ + a.backupOptions = database.BackupOptions{ BackupDir: a.cliParams.String("backup-dir"), BackupInterval: a.cliParams.Int("backup-interval"), 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() go hub.Run() 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 _ = a.db.PutKey("stul-meta/version", appVersion) // Create logger and endpoints 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 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 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) runtime.EventsEmit(ctx, "ready", true) @@ -92,7 +130,10 @@ func (a *App) startup(ctx context.Context) { }() // 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) { @@ -122,6 +163,10 @@ func (a *App) IsServerReady() bool { return a.ready.Get() } +func (a *App) IsFatalError() bool { + return a.isFatalError.Get() +} + func (a *App) GetKilovoltBind() string { if a.httpServer == nil { return "" @@ -145,6 +190,78 @@ func (a *App) GetDocumentation() map[string]docs.KeyObject { 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 { Release string `json:"release"` BuildInfo *debug.BuildInfo `json:"build"` @@ -157,3 +274,31 @@ func (a *App) GetAppVersion() VersionInfo { 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)) + } +} diff --git a/database/driver.pebble.go b/database/driver.pebble.go index 0f62b00..3c76e48 100644 --- a/database/driver.pebble.go +++ b/database/driver.pebble.go @@ -50,6 +50,9 @@ func (p *PebbleDatabase) Hub() *kv.Hub { } func (p *PebbleDatabase) Close() error { + if p.hub != nil { + p.hub.Close() + } err := p.db.Close() if err != nil { return fmt.Errorf("could not close database: %w", err) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index d09bd7d..626a02e 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -1,6 +1,6 @@ module.exports = { parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint', 'import'], + plugins: ['react-refresh', '@typescript-eslint', 'import'], extends: [ 'airbnb-base', 'plugin:@typescript-eslint/recommended', @@ -28,6 +28,7 @@ module.exports = { '@typescript-eslint/no-unsafe-return': ['error'], '@typescript-eslint/switch-exhaustiveness-check': ['error'], '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + 'react-refresh/only-export-components': 'warn' }, settings: { 'import/resolver': { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 601e930..56632bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -55,6 +55,7 @@ "eslint-import-resolver-typescript": "^3.5.4", "eslint-plugin-import": "^2.27.5", "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-react-refresh": "^0.3.4", "prettier": "^2.8.7", "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -7263,6 +7273,13 @@ "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 320c392..5fceeda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,6 +58,7 @@ "eslint-import-resolver-typescript": "^3.5.4", "eslint-plugin-import": "^2.27.5", "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-react-refresh": "^0.3.4", "prettier": "^2.8.7", "rimraf": "^4.4.1" } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 33a3a08..96f9a4c 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -dc4f2d866ac751f1391a998d03e939db \ No newline at end of file +293b48c0e126b4f88d9b5ba934df5908 \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b235653..75becb8 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,27 +1,47 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; 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 '@fontsource/space-mono/index.css'; import 'normalize.css/normalize.css'; - import './locale/setup'; import store from './store'; import App from './ui/App'; +import ErrorWindow from './ui/ErrorWindow'; import { globalStyles } from './ui/theme'; globalStyles(); +function AppWrapper() { + const [fatalErrorEncountered, setFatalErrorStatus] = useState(false); + useEffect(() => { + void IsFatalError().then(setFatalErrorStatus); + EventsOn('fatalError', () => { + setFatalErrorStatus(true); + }); + return () => { + EventsOff('fatalError'); + }; + }, []); + + if (fatalErrorEncountered) { + return ; + } + return ; +} + const main = document.getElementById('main'); const root = createRoot(main); root.render( - + , diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 6797ff6..0537fc1 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -339,6 +339,31 @@ "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-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 {{A}} and {{B}}.", + "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 {{A}} and {{B}}", + "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: {{code}} 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": { diff --git a/frontend/src/locale/it/translation.json b/frontend/src/locale/it/translation.json index dc52351..7c898a2 100644 --- a/frontend/src/locale/it/translation.json +++ b/frontend/src/locale/it/translation.json @@ -392,6 +392,29 @@ "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-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 {{A}} e {{B}}.", + "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 {{A}} e {{B}}", + "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": { diff --git a/frontend/src/store/logging/reducer.ts b/frontend/src/store/logging/reducer.ts index 687d3f4..8d20eeb 100644 --- a/frontend/src/store/logging/reducer.ts +++ b/frontend/src/store/logging/reducer.ts @@ -10,7 +10,7 @@ export interface ProcessedLogEntry { data: object; } -function processEntry({ +export function processEntry({ time, caller, level, diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 8fcee2d..d877840 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -11,7 +11,7 @@ import { } from '@radix-ui/react-icons'; import { EventsOff, EventsOn } from '@wailsapp/runtime/runtime'; 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 { diff --git a/frontend/src/ui/ErrorWindow.tsx b/frontend/src/ui/ErrorWindow.tsx new file mode 100644 index 0000000..7331097 --- /dev/null +++ b/frontend/src/ui/ErrorWindow.tsx @@ -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([]); + + useEffect(() => { + GetBackups().then((backupList) => { + setBackups(backupList); + console.log(backupList); + }); + }, []); + + return ( + { + if (onOpenChange) onOpenChange(state); + }} + > + + + These action will irreversibly modify your database, please make sure + your database is corrupted in the first place before proceeding. + + Restore from backup + + Restore a previously backed up database. This will overwrite your + current database with the saved copy. Check below for the list of + saved copies. + + + {backups + .sort((a, b) => b.date - a.date) + .map((backup) => ( + + + {new Date(backup.date).toLocaleString()} + + + + + + ))} + + + + ); +} + +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(null); + + const waiting = submitted && code.length < 1; + + if (code) { + return ( + { + if (onOpenChange) onOpenChange(state); + }} + > + { + setSubmissionError(null); + }} + > + + + ), + }} + /> + + + + ); + } + + return ( + <> + { + if (!val) setSubmissionError(null); + }} + > + { + setSubmissionError(null); + }} + /> + + { + if (onOpenChange) onOpenChange(state); + }} + > + +
{ + 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); + }} + > + + {t('pages.crash.report.thanks-line')} + + + {t('pages.crash.report.transparency-line')} +
    +
  • + , + }} + /> +
  • +
  • {t('pages.crash.report.transparency-info')}
  • +
  • {t('pages.crash.report.transparency-user')}
  • +
+
+ + + + + + + setContactInfo(e.target.value)} + /> + + + + +
+
+
+ + ); +} + +export default function ErrorWindow(): JSX.Element { + const [t, i18n] = useTranslation(); + const [logs, setLogs] = useState([]); + 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 ( + + + + + { + void i18n.changeLanguage(newLang); + }} + > + {languages.map((lang) => ( + + {lang.code} + + ))} + + + + + + {t('pages.crash.fatal-message')} + + {fatal ? ( + <> + {fatal.message} + + {Object.keys(fatal.data) + .filter((key) => key.length > 1) + .map((key) => ( + + {key} + {fatal.data[key]} + + ))} + + + ) : null} + {t('pages.crash.action-header')} + {t('pages.crash.action-submit-line')} + {t('pages.crash.action-recover-line')} + + , + }} + /> + + + + + + + {t('pages.crash.app-log-header')} + + {logs.map((log) => ( + + ))} + + + + + ); +} diff --git a/frontend/src/ui/components/LogViewer.tsx b/frontend/src/ui/components/LogViewer.tsx index fdfe315..045a5fa 100644 --- a/frontend/src/ui/components/LogViewer.tsx +++ b/frontend/src/ui/components/LogViewer.tsx @@ -108,6 +108,7 @@ const LevelToggle = styled(MultiToggleItem, { interface LogItemProps { data: ProcessedLogEntry; + expandDefault?: boolean; } const LogEntryContainer = styled('div', { @@ -231,12 +232,12 @@ const LogDetailKey = styled('div', { }); const LogDetailValue = styled('div', { flex: '1' }); -function LogItem({ data }: LogItemProps) { +export function LogItem({ data, expandDefault }: LogItemProps) { const { t } = useTranslation(); const levelStyle = isSupportedLevel(data.level) ? data.level : null; const details = Object.entries(data.data).filter(([key]) => key.length > 1); const [copied, setCopied] = useState(false); - const [showDetails, setShowDetails] = useState(false); + const [showDetails, setShowDetails] = useState(expandDefault ?? false); const copyToClipboard = async () => { await navigator.clipboard.writeText(JSON.stringify(data.data)); setCopied(true); diff --git a/frontend/src/ui/components/utils/Channels.tsx b/frontend/src/ui/components/utils/Channels.tsx new file mode 100644 index 0000000..16aa2e9 --- /dev/null +++ b/frontend/src/ui/components/utils/Channels.tsx @@ -0,0 +1,31 @@ +import { + DiscordLogoIcon, + EnvelopeClosedIcon, + GitHubLogoIcon, +} from '@radix-ui/react-icons'; +import { ChannelList, Channel, ChannelLink } from '../../pages/Strimertul'; + +export const Channels = ( + + + + + github.com/strimertul/strimertul/issues + + + + + + nebula.cafe/discord + + + + + + strimertul@nebula.cafe + + + +); + +export default Channels; diff --git a/frontend/src/ui/pages/Onboarding.tsx b/frontend/src/ui/pages/Onboarding.tsx index b62bbb3..1549238 100644 --- a/frontend/src/ui/pages/Onboarding.tsx +++ b/frontend/src/ui/pages/Onboarding.tsx @@ -23,6 +23,7 @@ import AlertContent from '../components/AlertContent'; import BrowserLink from '../components/BrowserLink'; import DefinitionTable from '../components/DefinitionTable'; import RevealLink from '../components/utils/RevealLink'; +import Channels from '../components/utils/Channels'; import { Button, @@ -39,7 +40,6 @@ import { TextBlock, } from '../theme'; import { Alert } from '../theme/alert'; -import { channels } from './Strimertul'; const Container = styled('div', { display: 'flex', @@ -582,7 +582,7 @@ function DoneStep() { {t('pages.onboarding.done-header')} {t('pages.onboarding.done-p1')} {t('pages.onboarding.done-p2')} - {channels} + {Channels} {t('pages.onboarding.done-p3')}