diff --git a/docker-compose.yml b/docker-compose.yml index a608a2c..ac0fe8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,9 @@ services: MUZICK_ADMIN_KEY: ${MUZICK_ADMIN_KEY} MUSIC_DIR: /music volumes: + # READ-ONLY, deliberately. Nothing in the API request path may write to + # the library. Hard deletion of disliked files happens only in the worker, + # which is the sole service with an rw mount. - /mnt/hdd1/media/Music:/music:ro depends_on: - db @@ -49,6 +52,7 @@ services: - "127.0.0.1:5174:80" environment: MUZICK_API_KEY: ${MUZICK_API_KEY} + MUZICK_ADMIN_KEY: ${MUZICK_ADMIN_KEY} depends_on: - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 21d6d64..2da9708 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,4 +9,4 @@ FROM nginx:stable-alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf.template /etc/nginx/templates/default.conf.template EXPOSE 80 -CMD ["/bin/sh", "-c", "envsubst '${MUZICK_API_KEY}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] +CMD ["/bin/sh", "-c", "envsubst '${MUZICK_API_KEY} ${MUZICK_ADMIN_KEY}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index f54f59e..0000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 80; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - location /api { - proxy_pass http://backend:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } -} diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index c39db7a..b1d9cf6 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -7,6 +7,19 @@ server { try_files $uri $uri/ /index.html; } + # Admin endpoints need the admin key, not the regular API key. This prefix + # location is longer than "/api", and nginx picks the longest matching + # prefix location, so it wins for /api/admin/* while /api handles the rest. + location /api/admin/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header Authorization "Bearer ${MUZICK_ADMIN_KEY}"; + proxy_cache_bypass $http_upgrade; + } + location /api { proxy_pass http://backend:3000; proxy_http_version 1.1; diff --git a/frontend/src/pages/Jobs.tsx b/frontend/src/pages/Jobs.tsx index c47c6ec..0555d85 100644 --- a/frontend/src/pages/Jobs.tsx +++ b/frontend/src/pages/Jobs.tsx @@ -317,6 +317,17 @@ function FilterBar({ ); } +/** Human-readable one-liner for a failed admin request. */ +function describeError(err: unknown): string { + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 401 || status === 403) { + return `Request rejected (HTTP ${status}) — the admin API key is missing or wrong.`; + } + if (status) return `Request failed with HTTP ${status}.`; + const message = err instanceof Error ? err.message : String(err); + return message || 'Unknown error.'; +} + /* ─────────────────────────────────────────── Page ────────────────────────────────────────────────── */ export default function JobsPage() { @@ -325,20 +336,26 @@ export default function JobsPage() { const [filters, setFilters] = useState(INITIAL_FILTERS); const [expandedIds, setExpandedIds] = useState>(new Set()); - const { data: stats, refetch: refetchStats } = useQuery({ + const statsQ = useQuery({ queryKey: ['queueStats'], queryFn: () => jobsService.getQueueStats(), - refetchInterval: autoRefresh ? 3000 : false, + // Stop polling once the endpoint is failing — otherwise a permission or + // outage error is retried silently every 3s forever. + refetchInterval: (query) => (autoRefresh && !query.state.error ? 3000 : false), staleTime: 1000, }); - const { data: history, refetch: refetchHistory } = useQuery({ + const historyQ = useQuery({ queryKey: ['jobHistory'], queryFn: () => jobsService.getJobHistory(200), - refetchInterval: autoRefresh ? 5000 : false, + refetchInterval: (query) => (autoRefresh && !query.state.error ? 5000 : false), staleTime: 2000, }); + const { data: stats, refetch: refetchStats } = statsQ; + const { data: history, refetch: refetchHistory } = historyQ; + const loadError = statsQ.error ?? historyQ.error; + useEffect(() => { if (selectedTab === 'overview') refetchStats(); else refetchHistory(); @@ -415,7 +432,27 @@ export default function JobsPage() { {/* ── Content ── */}
+ {/* ── Error banner ── */} + {loadError && ( +
+ +
+

Could not load job data

+

{describeError(loadError)}

+ +
+
+ )} + {/* ── Overview tab ── */} + {selectedTab === 'overview' && !stats && !loadError && ( +
Loading queue stats…
+ )} {selectedTab === 'overview' && stats && (