Skip to content

Commit e9e68d2

Browse files
Suncussclaude
andcommitted
feat: global recorder health warning indicator and Settings error details
Add amber pill FAB for degraded/stopped audio sources visible on all pages except Settings, with 24h dismiss and priority over the update indicator. Auto-expand recorder error details on Settings page. Include recorder status in stream config API. Add 200 per-page Table option. Fix GHCR pull JSON whitespace and parallel build race. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent c2ac8c2 commit e9e68d2

11 files changed

Lines changed: 184 additions & 61 deletions

File tree

.github/workflows/build-images.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ env:
1111

1212
jobs:
1313
build:
14+
if: github.event.repository.visibility == 'public'
1415
runs-on: ubuntu-24.04-arm
1516
permissions:
1617
contents: read

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
- Added global recorder health warning pill — amber FAB appears on all pages (except Settings) when audio sources are degraded or stopped, with 24-hour dismiss and priority over the update indicator
6+
- Added recorder status to stream config API response for REST-based health checks
7+
- Added auto-expanded error details on Settings page when audio sources have issues
8+
- Added 200 per-page option and scroll-to-top button in Table view
9+
- Fixed GHCR pull check never matched due to JSON whitespace in image inspect
10+
- Fixed parallel build race caused by duplicate build directives
511
- Added pre-built Docker image support — install pulls ARM64 images from GHCR on release branches for faster setup, falls back to local build for non-ARM64 platforms, non-standard UIDs, or non-release branches
612
- Added GitHub Actions CI workflow for building and pushing Docker images to GHCR
713
- Added install script unit tests for `set_env_var` function

backend/core/api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1146,7 +1146,10 @@ def get_stream_config():
11461146
'url': f'/stream/{sid}.mp3',
11471147
})
11481148

1149-
return jsonify({'streams': streams})
1149+
result = {'streams': streams}
1150+
if _recorder_status:
1151+
result['recorder_status'] = _recorder_status
1152+
return jsonify(result)
11501153

11511154
@api.route('/api/broadcast/detection', methods=['POST'])
11521155
@require_internal

frontend/src/App.vue

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,51 @@
108108
</router-view>
109109
</main>
110110

111+
<!-- Status FAB — recorder warning takes priority over update; hidden on Settings -->
112+
<router-link
113+
v-if="recorderHealth.showRecorderWarning.value && $route.name !== 'Settings'"
114+
to="/settings"
115+
class="fixed bottom-4 right-4 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg hidden md:flex items-center gap-2 z-50 transition-colors"
116+
title="Audio recording issues detected"
117+
@click="recorderHealth.dismissWarning()"
118+
>
119+
<svg
120+
class="w-5 h-5"
121+
fill="none"
122+
stroke="currentColor"
123+
viewBox="0 0 24 24"
124+
>
125+
<path
126+
stroke-linecap="round"
127+
stroke-linejoin="round"
128+
stroke-width="2"
129+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
130+
/>
131+
</svg>
132+
<span class="text-sm font-medium">Audio Recording Issues</span>
133+
</router-link>
134+
<router-link
135+
v-else-if="systemUpdate.showUpdateIndicator.value && $route.name !== 'Settings'"
136+
to="/settings"
137+
class="fixed bottom-4 right-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-full shadow-lg hidden md:flex items-center gap-2 z-50 transition-colors"
138+
title="System update available"
139+
>
140+
<svg
141+
class="w-5 h-5"
142+
fill="none"
143+
stroke="currentColor"
144+
viewBox="0 0 24 24"
145+
>
146+
<path
147+
stroke-linecap="round"
148+
stroke-linejoin="round"
149+
stroke-width="2"
150+
d="M5 10l7-7 7 7M12 3v18"
151+
/>
152+
</svg>
153+
<span class="text-sm font-medium">Update Available</span>
154+
</router-link>
155+
111156
<!-- Setup Wizard -->
112157
<SetupWizard
113158
:is-visible="showSetupWizard"
@@ -129,6 +174,8 @@ import { useLogger } from '@/composables/useLogger'
129174
import { useAuth } from '@/composables/useAuth'
130175
import { useUnitSettings } from '@/composables/useUnitSettings'
131176
import { useAppStatus } from '@/composables/useAppStatus'
177+
import { useSystemUpdate } from '@/composables/useSystemUpdate'
178+
import { useRecorderHealth } from '@/composables/useRecorderHealth'
132179
import { DISPLAY_NAME } from './version'
133180
import SetupWizard from '@/components/SetupWizard.vue'
134181
import LoginModal from '@/components/LoginModal.vue'
@@ -147,6 +194,8 @@ export default {
147194
const auth = useAuth()
148195
const unitSettings = useUnitSettings()
149196
const { stationName, setStationName, setLocationConfigured } = useAppStatus()
197+
const systemUpdate = useSystemUpdate()
198+
const recorderHealth = useRecorderHealth()
150199
151200
const showSetupWizard = ref(false)
152201
const showLoginModal = ref(false)
@@ -243,6 +292,10 @@ export default {
243292
} else {
244293
checkLocationSetup()
245294
}
295+
296+
// Silent checks for status indicators
297+
systemUpdate.checkForUpdates({ silent: true }).catch(() => {})
298+
recorderHealth.checkStatus()
246299
})
247300
248301
onUnmounted(() => {
@@ -257,7 +310,9 @@ export default {
257310
onLoginCancel,
258311
handleLogout,
259312
auth,
260-
stationName
313+
stationName,
314+
systemUpdate,
315+
recorderHealth
261316
}
262317
}
263318
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ref, computed } from 'vue'
2+
import api from '@/services/api'
3+
import { useAuth } from './useAuth'
4+
import { RECORDER_STATES } from '@/utils/recorderStates'
5+
6+
// Module-level state (shared across all components - singleton)
7+
const recorderStatus = ref(null)
8+
9+
// Dismissal state - stores expiry timestamp (when to show again)
10+
const DISMISS_STORAGE_KEY = 'birdnet_recorder_dismissed_until'
11+
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
12+
13+
function loadDismissedUntil() {
14+
try {
15+
const stored = localStorage.getItem(DISMISS_STORAGE_KEY)
16+
return stored ? parseInt(stored, 10) : null
17+
} catch {
18+
return null
19+
}
20+
}
21+
22+
const dismissedUntil = ref(loadDismissedUntil())
23+
24+
export function useRecorderHealth() {
25+
const { isAuthenticated } = useAuth()
26+
27+
const showRecorderWarning = computed(() => {
28+
const state = recorderStatus.value?.state
29+
if (state !== RECORDER_STATES.DEGRADED && state !== RECORDER_STATES.STOPPED) return false
30+
if (!isAuthenticated.value) return false
31+
return !dismissedUntil.value || Date.now() >= dismissedUntil.value
32+
})
33+
34+
const dismissWarning = () => {
35+
const expiry = Date.now() + DISMISS_DURATION_MS
36+
dismissedUntil.value = expiry
37+
localStorage.setItem(DISMISS_STORAGE_KEY, String(expiry))
38+
}
39+
40+
const checkStatus = async () => {
41+
try {
42+
const { data } = await api.get('/stream/config')
43+
if (data.recorder_status) {
44+
recorderStatus.value = data.recorder_status
45+
}
46+
} catch {
47+
// Silent failure — recorder health is non-critical UI
48+
}
49+
}
50+
51+
return {
52+
showRecorderWarning,
53+
dismissWarning,
54+
checkStatus
55+
}
56+
}

frontend/src/composables/useTableData.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ export function useTableData() {
317317
* @param {number} count - Items per page (25, 50, or 100)
318318
*/
319319
function setPerPage(count) {
320-
if ([25, 50, 100].includes(count)) {
320+
if ([25, 50, 100, 200].includes(count)) {
321321
perPage.value = count
322322
currentPage.value = 1
323323
fetchDetections()

frontend/src/views/Dashboard.vue

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,5 @@
11
<template>
22
<div class="dashboard">
3-
<!-- Update FAB -->
4-
<router-link
5-
v-if="systemUpdate.showUpdateIndicator.value"
6-
to="/settings"
7-
class="fixed bottom-4 right-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-full shadow-lg hidden md:flex items-center gap-2 z-50 transition-colors"
8-
title="System update available"
9-
>
10-
<svg
11-
class="w-5 h-5"
12-
fill="none"
13-
stroke="currentColor"
14-
viewBox="0 0 24 24"
15-
>
16-
<path
17-
stroke-linecap="round"
18-
stroke-linejoin="round"
19-
stroke-width="2"
20-
d="M5 10l7-7 7 7M12 3v18"
21-
/>
22-
</svg>
23-
<span class="text-sm font-medium">Update Available</span>
24-
</router-link>
25-
263
<!-- Dashboard content (hidden during setup via locationConfigured check) -->
274
<div
285
v-if="locationConfigured !== false"
@@ -410,7 +387,6 @@ import { faPlay, faPause, faCircleInfo, faExternalLinkAlt } from '@fortawesome/f
410387
import { useBirdCharts } from '@/composables/useBirdCharts';
411388
import { useAudioPlayer } from '@/composables/useAudioPlayer';
412389
import { useAppStatus } from '@/composables/useAppStatus';
413-
import { useSystemUpdate } from '@/composables/useSystemUpdate';
414390
import SpectrogramModal from '@/components/SpectrogramModal.vue';
415391
import CenteredMessage from '@/components/CenteredMessage.vue';
416392
import { getAudioUrl, getSpectrogramUrl } from '@/services/media'
@@ -518,9 +494,6 @@ export default {
518494
// App status for coordinating with setup flow
519495
const { locationConfigured } = useAppStatus()
520496
521-
// System update composable for silent auto-check
522-
const systemUpdate = useSystemUpdate()
523-
524497
const currentOrder = () => showLeastCommon.value ? 'least' : 'most'
525498
const recentMode = () => showUniqueSpecies.value ? 'unique' : 'all'
526499
@@ -575,9 +548,6 @@ export default {
575548
if (!isActive) return // Deactivated while fetching — bail out
576549
startPolling()
577550
578-
// Silent auto-check for updates (no status messages, uses backend cache)
579-
systemUpdate.checkForUpdates({ silent: true }).catch(() => {})
580-
581551
// Wait for DOM to render canvas elements (they're behind v-if="!isDataEmpty")
582552
await nextTick()
583553
@@ -902,7 +872,6 @@ export default {
902872
togglePlayBirdCall,
903873
currentPlayingId,
904874
latestObservationimageUrl,
905-
systemUpdate,
906875
showLeastCommon,
907876
toggleActivityOrder,
908877
isActivityUpdating,

frontend/src/views/LiveFeed.vue

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@
2222
ref="spectrogramCanvas"
2323
class="w-full h-48 mb-4 rounded-lg"
2424
/>
25-
<AlertBanner
26-
:message="recorderWarning"
27-
:dismissible="false"
28-
:auto-dismiss="0"
29-
/>
3025
<div
3126
v-if="streams.length > 1"
3227
class="flex flex-wrap gap-2 mb-4"
@@ -97,15 +92,12 @@
9792
<script>
9893
import { ref, computed, onMounted, onUnmounted } from 'vue'
9994
import { io } from 'socket.io-client'
100-
import AlertBanner from '@/components/AlertBanner.vue'
10195
import BirdDetectionList from './BirdDetectionList.vue'
10296
import api from '@/services/api'
103-
import { RECORDER_STATES } from '@/utils/recorderStates'
10497
10598
export default {
10699
name: 'LiveFeed',
107100
components: {
108-
AlertBanner,
109101
BirdDetectionList
110102
},
111103
setup() {
@@ -118,22 +110,13 @@ export default {
118110
const birdDetections = ref([])
119111
const streams = ref([])
120112
const selectedSourceId = ref('')
121-
const recorderStatus = ref(null)
122113
123114
const currentSource = computed(() =>
124115
streams.value.find(s => s.source_id === selectedSourceId.value)
125116
)
126117
const streamUrl = computed(() => currentSource.value?.url || '')
127118
const streamDescription = computed(() => currentSource.value?.label || '')
128119
129-
const recorderWarning = computed(() => {
130-
const status = recorderStatus.value
131-
if (!status || status.state === RECORDER_STATES.RUNNING) return ''
132-
if (status.state === RECORDER_STATES.STOPPED) return 'Audio recording has stopped'
133-
if (status.state === RECORDER_STATES.DEGRADED) return 'Audio recording is experiencing issues'
134-
return ''
135-
})
136-
137120
let audioContext, analyser, source, dataArray, animationId
138121
let canvasCtx, canvasWidth, canvasHeight
139122
let socket
@@ -354,10 +337,6 @@ export default {
354337
streams.value = config.streams || []
355338
selectedSourceId.value = streams.value[0]?.source_id || ''
356339
357-
if (config.recorder_status) {
358-
recorderStatus.value = config.recorder_status
359-
}
360-
361340
if (!streamUrl.value) {
362341
statusMessage.value = 'No audio stream configured'
363342
}
@@ -378,10 +357,6 @@ export default {
378357
console.log(`[LiveFeed] WebSocket disconnected: ${reason}`)
379358
})
380359
381-
socket.on('recorder_status', (status) => {
382-
recorderStatus.value = status
383-
})
384-
385360
socket.on('bird_detected', (detection) => {
386361
387362
// Find existing detection for this bird species
@@ -491,7 +466,6 @@ export default {
491466
streamUrl,
492467
streamDescription,
493468
isSafari,
494-
recorderWarning,
495469
selectSourceById,
496470
handleAudioError,
497471
handleAudioBuffering,

frontend/src/views/Settings.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,12 @@
277277
<!-- Error details (only when source is unhealthy) -->
278278
<details
279279
v-if="showRecorderError"
280+
open
280281
class="mt-2.5"
281282
>
282283
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-600 select-none">
283-
Show error details
284+
<span class="show-label">Show error details</span>
285+
<span class="hide-label">Hide error details</span>
284286
</summary>
285287
<div class="mt-1 space-y-1.5 relative group">
286288
<div
@@ -2685,4 +2687,10 @@ export default {
26852687
input[type="range"]:hover::-moz-range-thumb {
26862688
background-color: theme('colors.blue.700');
26872689
}
2690+
2691+
/* Toggle show/hide label based on details open state */
2692+
details .hide-label { display: none; }
2693+
details .show-label { display: inline; }
2694+
details[open] .hide-label { display: inline; }
2695+
details[open] .show-label { display: none; }
26882696
</style>

0 commit comments

Comments
 (0)