fix(scanner): store scan errors in the database and update UI error handling
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -224,6 +224,10 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
|
|||||||
for _, w := range scanWarnings {
|
for _, w := range scanWarnings {
|
||||||
log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w))
|
log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w))
|
||||||
}
|
}
|
||||||
|
// Store scan error in database so it can be displayed in the UI
|
||||||
|
if scanError != nil {
|
||||||
|
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error())
|
||||||
|
}
|
||||||
// If changes were detected, send a refresh event to all clients
|
// If changes were detected, send a refresh event to all clients
|
||||||
if s.changesDetected {
|
if s.changesDetected {
|
||||||
log.Debug(ctx, "Library changes imported. Sending refresh event")
|
log.Debug(ctx, "Library changes imported. Sending refresh event")
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
|
|||||||
job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
|
job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
|
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
|
||||||
state.sendWarning(err.Error())
|
state.sendError(err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
jobs = append(jobs, job)
|
jobs = append(jobs, job)
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.MusicFolder = "default:///music" // Use a distinct schema for the default library
|
||||||
conf.Server.DevExternalScanner = false
|
conf.Server.DevExternalScanner = false
|
||||||
|
|
||||||
|
// Register an empty fake storage for the default library
|
||||||
|
emptyFS := storagetest.FakeFS{}
|
||||||
|
emptyFS.SetFiles(fstest.MapFS{})
|
||||||
|
storagetest.Register("default", &emptyFS)
|
||||||
|
|
||||||
db.Init(ctx)
|
db.Init(ctx)
|
||||||
DeferCleanup(func() {
|
DeferCleanup(func() {
|
||||||
Expect(tests.ClearDB()).To(Succeed())
|
Expect(tests.ClearDB()).To(Succeed())
|
||||||
@@ -770,7 +776,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
|||||||
// Second scan should recover and import all rock content
|
// Second scan should recover and import all rock content
|
||||||
warnings, err = s.ScanAll(ctx, true)
|
warnings, err = s.ScanAll(ctx, true)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
|
Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery")
|
||||||
|
|
||||||
// Verify both libraries now have content (at least jazz should work)
|
// Verify both libraries now have content (at least jazz should work)
|
||||||
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import { FiActivity } from 'react-icons/fi'
|
import { FiActivity } from 'react-icons/fi'
|
||||||
import { BiError } from 'react-icons/bi'
|
import { BiError, BiMessageError } from 'react-icons/bi'
|
||||||
import { VscSync } from 'react-icons/vsc'
|
import { VscSync } from 'react-icons/vsc'
|
||||||
import { GiMagnifyingGlass } from 'react-icons/gi'
|
import { GiMagnifyingGlass } from 'react-icons/gi'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
@@ -28,7 +28,12 @@ import config from '../config'
|
|||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
color: (props) => (props.up ? null : 'orange'),
|
color: (props) =>
|
||||||
|
props.serverDown
|
||||||
|
? theme.palette.error.main
|
||||||
|
: props.hasWarning
|
||||||
|
? theme.palette.warning.main
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: theme.palette.primary.light,
|
color: theme.palette.primary.light,
|
||||||
@@ -75,12 +80,10 @@ const ActivityPanel = () => {
|
|||||||
scanStatus.scanning,
|
scanStatus.scanning,
|
||||||
scanStatus.elapsedTime,
|
scanStatus.elapsedTime,
|
||||||
)
|
)
|
||||||
const [acknowledgedError, setAcknowledgedError] = useState(null)
|
// Determine icon state: error (server down), warning (scan error), or normal
|
||||||
const isErrorVisible =
|
const serverDown = !up
|
||||||
scanStatus.error && scanStatus.error !== acknowledgedError
|
const hasWarning = Boolean(scanStatus.error)
|
||||||
const classes = useStyles({
|
const classes = useStyles({ serverDown, hasWarning })
|
||||||
up: up && (!scanStatus.error || !isErrorVisible),
|
|
||||||
})
|
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
@@ -88,13 +91,12 @@ const ActivityPanel = () => {
|
|||||||
useInitialScanStatus()
|
useInitialScanStatus()
|
||||||
|
|
||||||
const handleMenuOpen = (event) => {
|
const handleMenuOpen = (event) => {
|
||||||
if (scanStatus.error) {
|
|
||||||
setAcknowledgedError(scanStatus.error)
|
|
||||||
}
|
|
||||||
setAnchorEl(event.currentTarget)
|
setAnchorEl(event.currentTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuClose = () => setAnchorEl(null)
|
const handleMenuClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
|
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,8 +127,10 @@ const ActivityPanel = () => {
|
|||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<Tooltip title={tooltipTitle}>
|
<Tooltip title={tooltipTitle}>
|
||||||
<IconButton className={classes.button} onClick={handleMenuOpen}>
|
<IconButton className={classes.button} onClick={handleMenuOpen}>
|
||||||
{!up || isErrorVisible ? (
|
{serverDown ? (
|
||||||
<BiError data-testid="activity-error-icon" size={'20'} />
|
<BiError data-testid="activity-error-icon" size={'20'} />
|
||||||
|
) : hasWarning ? (
|
||||||
|
<BiMessageError data-testid="activity-warning-icon" size={'20'} />
|
||||||
) : (
|
) : (
|
||||||
<FiActivity data-testid="activity-ok-icon" size={'20'} />
|
<FiActivity data-testid="activity-ok-icon" size={'20'} />
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +159,11 @@ const ActivityPanel = () => {
|
|||||||
<Box component="span" flex={2}>
|
<Box component="span" flex={2}>
|
||||||
{translate('activity.serverUptime')}:
|
{translate('activity.serverUptime')}:
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="span" flex={1}>
|
<Box
|
||||||
|
component="span"
|
||||||
|
flex={1}
|
||||||
|
className={!up ? classes.error : null}
|
||||||
|
>
|
||||||
{up ? <Uptime /> : translate('activity.serverDown')}
|
{up ? <Uptime /> : translate('activity.serverDown')}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -43,19 +43,47 @@ describe('<ActivityPanel />', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears the error icon after opening the panel', () => {
|
it('shows warning icon when server reports a scan error', () => {
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ActivityPanel />
|
<ActivityPanel />
|
||||||
</Provider>,
|
</Provider>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Warning icon should be visible when there's a scan error
|
||||||
|
expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Open the panel - warning icon should still be visible
|
||||||
const button = screen.getByRole('button')
|
const button = screen.getByRole('button')
|
||||||
expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(button)
|
fireEvent.click(button)
|
||||||
|
expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Scan failed')).toBeInTheDocument()
|
expect(screen.getByText('Scan failed')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows error icon when server is down', () => {
|
||||||
|
const downStore = createStore(
|
||||||
|
combineReducers({ activity: activityReducer }),
|
||||||
|
{
|
||||||
|
activity: {
|
||||||
|
scanStatus: {
|
||||||
|
scanning: false,
|
||||||
|
folderCount: 0,
|
||||||
|
count: 0,
|
||||||
|
error: '',
|
||||||
|
elapsedTime: 0,
|
||||||
|
},
|
||||||
|
serverStart: { version: config.version, startTime: null }, // null startTime = server down
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Provider store={downStore}>
|
||||||
|
<ActivityPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error icon should be visible when server is down
|
||||||
|
expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user