'use client'; import { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import BackupManagerModal from './BackupManagerModal'; import * as Icons from './Icons'; import { DOCTOR_LEVEL_EVENT } from './systemHealth'; import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor'; import type { AltTextReport } from '@/lib/admin/alt-text'; /** * Diagnostics as a page (/admin/diagnostics). * * It started as a modal (#491), which was fine for seven one-line findings and * nothing else: a photo list does not fit in 640px, a modal cannot be linked * from an issue, and backups and the cache lived in two further places. This * page holds all of it, worst news first. * * The status badge in the top bar still answers only from the doctor. Alt text * is shown here but kept out of the badge on purpose: most libraries have some * photos without a description, and a badge that is never green stops being * read. */ interface StatusData { immich?: { status: 'connected' | 'disconnected' }; config?: { status: 'valid' | 'invalid' | 'setup'; gallery: string; settings: string }; cache?: { size: number }; backups?: { count: number; lastBackup: string | null }; update?: { current: string; latest?: string; updateAvailable?: boolean }; } type AltTextData = AltTextReport & { unreadable: number; immichUrl: string; /** `external-domain` is Immich's own public address; `api-url` may be internal. */ immichUrlSource: 'external-domain' | 'api-url'; }; /** Which group a doctor check belongs to. An unknown id lands in Content. */ const GROUPS: { id: string; title: string; checks: string[] }[] = [ { id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] }, { id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] }, { id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] }, ]; const LEVEL_LABEL: Record = { ok: 'OK', warn: 'Check', error: 'Problem' }; const LEVEL_RANK: Record = { error: 0, warn: 1, ok: 2 }; const DOCS = 'https://github.com/ralksta/immich-folio/blob/main'; /** Where each doctor finding is fixed. Admin paths are in-app links. */ const FIXES: Record = { 'auth-secret': { label: 'How to set it', href: `${DOCS}/README.md#environment-variables-envlocal`, }, 'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` }, passwords: { label: 'Open settings', href: '/admin/settings/security' }, 'album-ids': { label: 'Open pages', href: '/admin/pages' }, 'albums-shared': { label: 'Open pages', href: '/admin/pages' }, 'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` }, 'immich-api': { label: 'API key permissions', href: `${DOCS}/README.md#immich-api-key-permissions`, }, }; /** Photos shown per album before the "+n more" tile. */ const PREVIEW_COUNT = 12; function groupOf(id: string): string { return GROUPS.find((g) => g.checks.includes(id))?.id ?? 'content'; } /** The alt-text report, phrased as one more finding so it sorts with the rest. */ function altTextFinding(report: AltTextData): DoctorFinding { if (!report.captionsEnabled) { return { id: 'alt-text', level: 'warn', title: 'Photo descriptions are not used as alt text', detail: '“Photo Description” is switched off in Settings → General, so every photo on the site has an empty alt text, whatever Immich holds.', }; } const missing = report.total - report.described; const unread = report.unreadable ? ` ${report.unreadable} ${report.unreadable === 1 ? 'album' : 'albums'} could not be read and are not counted.` : ''; if (!missing) { return { id: 'alt-text', level: 'ok', title: `All ${report.total} published photos have alt text`, detail: `Every photo carries its Immich description.${unread}`, }; } return { id: 'alt-text', level: 'warn', title: `${missing} published ${missing === 1 ? 'photo has' : 'photos have'} no alt text`, detail: `Folio uses the Immich description as alt text. Without one, screen readers announce nothing and search engines index nothing for the photo.${unread}`, }; } export default function DiagnosticsView() { const [status, setStatus] = useState(null); const [findings, setFindings] = useState(null); const [altText, setAltText] = useState(null); const [altTextFailed, setAltTextFailed] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [ranAt, setRanAt] = useState(null); const [copied, setCopied] = useState(false); const [showBackups, setShowBackups] = useState(false); const [clearing, setClearing] = useState(false); const [altTextOpen, setAltTextOpen] = useState(false); const [expandedAlbums, setExpandedAlbums] = useState>(new Set()); const fetchStatus = useCallback(async () => { try { const res = await fetch('/api/admin/status'); setStatus(res.ok ? await res.json() : null); } catch { setStatus(null); } }, []); const run = useCallback(async () => { setLoading(true); setError(null); setCopied(false); // The alt-text report reads every published album and is the slow one, so // it arrives on its own and does not hold up the rest of the page. setAltTextFailed(false); fetch('/api/admin/alt-text') .then(async (res) => { if (!res.ok) throw new Error(); setAltText(await res.json()); }) .catch(() => { setAltText(null); setAltTextFailed(true); }); try { const [doctorRes] = await Promise.all([fetch('/api/admin/doctor'), fetchStatus()]); if (!doctorRes.ok) throw new Error(`Diagnostics failed (${doctorRes.status})`); const data = await doctorRes.json(); setFindings(data.findings || []); setRanAt(new Date()); if (data.level) { window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: data.level })); } } catch (err) { setError(err instanceof Error ? err.message : 'Diagnostics failed'); } finally { setLoading(false); } }, [fetchStatus]); useEffect(() => { run(); }, [run]); async function clearCache() { setClearing(true); try { await fetch('/api/admin/reload', { method: 'POST' }); await fetchStatus(); } finally { setClearing(false); } } const all: DoctorFinding[] = [...(findings ?? []), ...(altText ? [altTextFinding(altText)] : [])]; const attention = all .filter((f) => f.level !== 'ok') .sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level]); /** Markdown, so it can be pasted straight into a GitHub issue. */ async function copyReport() { const body = all .map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`) .join('\n'); const version = status?.update?.current ? `\nVersion ${status.update.current}\n` : ''; try { await navigator.clipboard.writeText(`### Immich Folio diagnostics\n${version}\n${body}\n`); setCopied(true); } catch { setError('Could not copy — your browser refused clipboard access.'); } } function toggleAlbum(id: string) { setExpandedAlbums((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } const headline = loading ? 'Checking…' : attention.length === 0 ? 'Nothing needs your attention' : `${attention.length} of ${all.length} checks want a look`; const renderAltTextList = (report: AltTextData) => (
{report.described} of {report.total} described ·{' '} {Math.round((report.described / report.total) * 100)}%
{report.albums.map((album) => { const expanded = expandedAlbums.has(album.albumId); const shown = expanded ? album.missing : album.missing.slice(0, PREVIEW_COUNT); const hidden = album.missing.length - shown.length; return (
{album.albumName} {album.path} {album.missing.length} of {album.total} missing
{shown.map((gap) => ( {/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnail proxy, not an optimisable image */} {gap.fileName} ))} {hidden > 0 && ( )}
); })}

Add the description in Immich, then press Reload in the top bar.{' '} {report.immichUrlSource === 'external-domain' ? ( <> Photos open at {report.immichUrl}, Immich’s external domain. ) : ( <> Photos open at {report.immichUrl} — the address Folio reaches Immich at, which your browser may not be able to open. Set External domain in Immich (Administration → Settings → Server) and the links use that instead. )}

); /** * The way to the fix, for a finding that wants one. Where the fix lives in * the admin the button goes there; where it is an environment variable or a * volume, it goes to the part of the docs that explains it. A finding with * no entry gets no button rather than a label that looks like one. */ const renderFix = (f: DoctorFinding) => { const id = f.id; // A finding about exactly one album opens the page it is published on. if (f.albumIds?.length === 1) { return ( Open in Pages ); } if (id === 'alt-text') { if (altText && !altText.captionsEnabled) { return ( Open settings ); } return null; // The photo list toggle is rendered separately. } const fix = FIXES[id]; if (!fix) return null; return fix.href.startsWith('/') ? ( {fix.label} ) : ( {fix.label} ↗ ); }; const renderFinding = (f: DoctorFinding) => { const isAltText = f.id === 'alt-text' && altText && altText.captionsEnabled; const hasList = isAltText && altText.albums.length > 0; return (
  • {hasList && altTextOpen && renderAltTextList(altText)}
  • ); }; const okIn = (groupId: string) => all.filter((f) => f.level === 'ok' && groupOf(f.id) === groupId); const immichOk = status?.immich?.status === 'connected'; const configState = status?.config?.status; return (

    {headline}

    {ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}

    {error &&
    {error}
    }
    Immich {status ? (immichOk ? 'Connected' : 'Disconnected') : '—'}
    Config {configState === 'valid' ? 'Valid' : configState === 'invalid' ? 'Invalid' : configState === 'setup' ? 'Not set up' : '—'}
    Backups {status?.backups?.count ?? '—'} {status?.backups?.lastBackup ? `latest ${new Date(status.backups.lastBackup).toLocaleDateString()}` : 'none yet'}
    Version {status?.update?.current ?? '—'} {status?.update?.updateAvailable ? ( {status.update.latest} available ) : status?.update?.current ? ( 'up to date' ) : ( '' )}
    {loading && !findings ? (
    ) : ( <> {attention.length > 0 && (

    Needs attention

      {attention.map(renderFinding)}
    )} {GROUPS.map((group) => { const ok = okIn(group.id); const pendingAltText = group.id === 'content' && !altText && !altTextFailed; const failedAltText = group.id === 'content' && altTextFailed; if (!ok.length && !pendingAltText && !failedAltText) { return null; } return (

    {group.title}

      {ok.map(renderFinding)} {pendingAltText && (
    • )} {failedAltText && (
    • )}
    ); })}

    Storage & cache

    )} setShowBackups(false)} onRestoreSuccess={fetchStatus} />
    ); }