575 lines
21 KiB
TypeScript
575 lines
21 KiB
TypeScript
|
|
'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<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
||
|
|
const LEVEL_RANK: Record<DoctorLevel, number> = { 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<string, { label: string; href: string }> = {
|
||
|
|
'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<StatusData | null>(null);
|
||
|
|
const [findings, setFindings] = useState<DoctorFinding[] | null>(null);
|
||
|
|
const [altText, setAltText] = useState<AltTextData | null>(null);
|
||
|
|
const [altTextFailed, setAltTextFailed] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [ranAt, setRanAt] = useState<Date | null>(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<Set<string>>(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) => (
|
||
|
|
<div className="diag-alt" id="diag-alt-list">
|
||
|
|
<div className="diag-coverage">
|
||
|
|
<div className="diag-coverage-bar" aria-hidden="true">
|
||
|
|
<span style={{ width: `${Math.round((report.described / report.total) * 100)}%` }} />
|
||
|
|
</div>
|
||
|
|
<span>
|
||
|
|
{report.described} of {report.total} described ·{' '}
|
||
|
|
{Math.round((report.described / report.total) * 100)}%
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{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 (
|
||
|
|
<div className="diag-album" key={album.albumId}>
|
||
|
|
<div className="diag-album-head">
|
||
|
|
<span className="diag-album-name">
|
||
|
|
{album.albumName}
|
||
|
|
<span className="diag-album-path">{album.path}</span>
|
||
|
|
</span>
|
||
|
|
<span className="diag-album-count">
|
||
|
|
{album.missing.length} of {album.total} missing
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-thumbs">
|
||
|
|
{shown.map((gap) => (
|
||
|
|
<a
|
||
|
|
key={gap.assetId}
|
||
|
|
className="diag-thumb"
|
||
|
|
href={`${report.immichUrl}/photos/${gap.assetId}`}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
title={`${gap.fileName} — open in Immich`}
|
||
|
|
>
|
||
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnail proxy, not an optimisable image */}
|
||
|
|
<img src={`/api/admin/thumbnail/${gap.assetId}`} alt="" loading="lazy" />
|
||
|
|
<span className="diag-thumb-name">{gap.fileName}</span>
|
||
|
|
</a>
|
||
|
|
))}
|
||
|
|
{hidden > 0 && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="diag-thumb-more"
|
||
|
|
onClick={() => toggleAlbum(album.albumId)}
|
||
|
|
>
|
||
|
|
+{hidden} more
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
<p className="diag-note">
|
||
|
|
Add the description in Immich, then press Reload in the top bar.{' '}
|
||
|
|
{report.immichUrlSource === 'external-domain' ? (
|
||
|
|
<>
|
||
|
|
Photos open at <code>{report.immichUrl}</code>, Immich’s external domain.
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
Photos open at <code>{report.immichUrl}</code> — the address Folio reaches Immich at,
|
||
|
|
which your browser may not be able to open. Set <strong>External domain</strong> in
|
||
|
|
Immich (Administration → Settings → Server) and the links use that instead.
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 (
|
||
|
|
<Link
|
||
|
|
href={`/admin/pages?album=${encodeURIComponent(f.albumIds[0])}`}
|
||
|
|
className="admin-btn admin-btn-sm"
|
||
|
|
>
|
||
|
|
Open in Pages
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (id === 'alt-text') {
|
||
|
|
if (altText && !altText.captionsEnabled) {
|
||
|
|
return (
|
||
|
|
<Link href="/admin/settings/general" className="admin-btn admin-btn-sm">
|
||
|
|
Open settings
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return null; // The photo list toggle is rendered separately.
|
||
|
|
}
|
||
|
|
const fix = FIXES[id];
|
||
|
|
if (!fix) return null;
|
||
|
|
return fix.href.startsWith('/') ? (
|
||
|
|
<Link href={fix.href} className="admin-btn admin-btn-sm">
|
||
|
|
{fix.label}
|
||
|
|
</Link>
|
||
|
|
) : (
|
||
|
|
<a
|
||
|
|
href={fix.href}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="admin-btn admin-btn-sm"
|
||
|
|
>
|
||
|
|
{fix.label} ↗
|
||
|
|
</a>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderFinding = (f: DoctorFinding) => {
|
||
|
|
const isAltText = f.id === 'alt-text' && altText && altText.captionsEnabled;
|
||
|
|
const hasList = isAltText && altText.albums.length > 0;
|
||
|
|
return (
|
||
|
|
<li key={f.id} className={`diag-check diag-check--${f.level}`}>
|
||
|
|
<div className="diag-check-row">
|
||
|
|
<span className="diag-dot" aria-hidden="true" />
|
||
|
|
<div className="diag-check-body">
|
||
|
|
<span className="diag-check-title">
|
||
|
|
{f.level !== 'ok' && <span className="sr-only">{LEVEL_LABEL[f.level]}: </span>}
|
||
|
|
{f.title}
|
||
|
|
</span>
|
||
|
|
<span className="diag-check-detail">{f.detail}</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-check-side">
|
||
|
|
{f.level !== 'ok' && renderFix(f)}
|
||
|
|
{hasList && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="admin-btn admin-btn-sm"
|
||
|
|
aria-expanded={altTextOpen}
|
||
|
|
aria-controls="diag-alt-list"
|
||
|
|
onClick={() => setAltTextOpen(!altTextOpen)}
|
||
|
|
>
|
||
|
|
{altTextOpen ? 'Hide photos' : 'Show photos'}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{hasList && altTextOpen && renderAltTextList(altText)}
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<div className="diag-page">
|
||
|
|
<div className="diag-head">
|
||
|
|
<div>
|
||
|
|
<h2>{headline}</h2>
|
||
|
|
<p className="diag-sub">
|
||
|
|
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="diag-actions">
|
||
|
|
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
|
||
|
|
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className="admin-btn admin-btn-sm"
|
||
|
|
onClick={copyReport}
|
||
|
|
disabled={loading || !all.length}
|
||
|
|
title="Markdown, ready to paste into an issue"
|
||
|
|
>
|
||
|
|
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{error && <div className="admin-error">{error}</div>}
|
||
|
|
|
||
|
|
<section className="diag-vitals" aria-label="Overview">
|
||
|
|
<div className="diag-vital">
|
||
|
|
<span className="diag-vital-label">Immich</span>
|
||
|
|
<span className="diag-vital-val">
|
||
|
|
<span
|
||
|
|
className={`diag-dot diag-dot--${status ? (immichOk ? 'ok' : 'error') : 'unknown'}`}
|
||
|
|
/>
|
||
|
|
{status ? (immichOk ? 'Connected' : 'Disconnected') : '—'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-vital">
|
||
|
|
<span className="diag-vital-label">Config</span>
|
||
|
|
<span className="diag-vital-val">
|
||
|
|
<span
|
||
|
|
className={`diag-dot diag-dot--${
|
||
|
|
configState === 'valid' ? 'ok' : configState === 'invalid' ? 'error' : 'unknown'
|
||
|
|
}`}
|
||
|
|
/>
|
||
|
|
{configState === 'valid'
|
||
|
|
? 'Valid'
|
||
|
|
: configState === 'invalid'
|
||
|
|
? 'Invalid'
|
||
|
|
: configState === 'setup'
|
||
|
|
? 'Not set up'
|
||
|
|
: '—'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-vital">
|
||
|
|
<span className="diag-vital-label">Backups</span>
|
||
|
|
<span className="diag-vital-val">{status?.backups?.count ?? '—'}</span>
|
||
|
|
<span className="diag-vital-meta">
|
||
|
|
{status?.backups?.lastBackup
|
||
|
|
? `latest ${new Date(status.backups.lastBackup).toLocaleDateString()}`
|
||
|
|
: 'none yet'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-vital">
|
||
|
|
<span className="diag-vital-label">Version</span>
|
||
|
|
<span className="diag-vital-val">{status?.update?.current ?? '—'}</span>
|
||
|
|
<span className="diag-vital-meta">
|
||
|
|
{status?.update?.updateAvailable ? (
|
||
|
|
<a
|
||
|
|
href="https://github.com/ralksta/immich-folio/releases/latest"
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
>
|
||
|
|
{status.update.latest} available
|
||
|
|
</a>
|
||
|
|
) : status?.update?.current ? (
|
||
|
|
'up to date'
|
||
|
|
) : (
|
||
|
|
''
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{loading && !findings ? (
|
||
|
|
<div className="admin-loading">
|
||
|
|
<div className="admin-spinner" />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{attention.length > 0 && (
|
||
|
|
<section className="diag-group" aria-labelledby="diag-attention">
|
||
|
|
<h3 id="diag-attention" className="diag-group-title">
|
||
|
|
Needs attention
|
||
|
|
</h3>
|
||
|
|
<ul className="diag-checks">{attention.map(renderFinding)}</ul>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{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 (
|
||
|
|
<section key={group.id} className="diag-group" aria-labelledby={`diag-${group.id}`}>
|
||
|
|
<h3 id={`diag-${group.id}`} className="diag-group-title">
|
||
|
|
{group.title}
|
||
|
|
</h3>
|
||
|
|
<ul className="diag-checks">
|
||
|
|
{ok.map(renderFinding)}
|
||
|
|
{pendingAltText && (
|
||
|
|
<li className="diag-check diag-check--unknown">
|
||
|
|
<div className="diag-check-row">
|
||
|
|
<span className="diag-dot" aria-hidden="true" />
|
||
|
|
<div className="diag-check-body">
|
||
|
|
<span className="diag-check-title">Checking alt text…</span>
|
||
|
|
<span className="diag-check-detail">
|
||
|
|
Reading every published album; large libraries take a moment.
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
)}
|
||
|
|
{failedAltText && (
|
||
|
|
<li className="diag-check diag-check--unknown">
|
||
|
|
<div className="diag-check-row">
|
||
|
|
<span className="diag-dot" aria-hidden="true" />
|
||
|
|
<div className="diag-check-body">
|
||
|
|
<span className="diag-check-title">Alt text could not be checked</span>
|
||
|
|
<span className="diag-check-detail">
|
||
|
|
The albums did not load. If Immich is disconnected, that is the reason —
|
||
|
|
try again once it is back.
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
)}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
<section className="diag-group" aria-labelledby="diag-storage">
|
||
|
|
<h3 id="diag-storage" className="diag-group-title">
|
||
|
|
Storage & cache
|
||
|
|
</h3>
|
||
|
|
<ul className="diag-checks">
|
||
|
|
<li className="diag-check diag-check--ok">
|
||
|
|
<div className="diag-check-row">
|
||
|
|
<span className="diag-dot" aria-hidden="true" />
|
||
|
|
<div className="diag-check-body">
|
||
|
|
<span className="diag-check-title">
|
||
|
|
{status?.backups?.count ?? 0} config backups
|
||
|
|
</span>
|
||
|
|
<span className="diag-check-detail">
|
||
|
|
Written before every admin save, ten per file.
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-check-side">
|
||
|
|
<button className="admin-btn admin-btn-sm" onClick={() => setShowBackups(true)}>
|
||
|
|
<Icons.IconArchive size={14} /> Manage backups
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
<li className="diag-check diag-check--ok">
|
||
|
|
<div className="diag-check-row">
|
||
|
|
<span className="diag-dot" aria-hidden="true" />
|
||
|
|
<div className="diag-check-body">
|
||
|
|
<span className="diag-check-title">
|
||
|
|
In-memory cache holds {status?.cache?.size ?? 0} items
|
||
|
|
</span>
|
||
|
|
<span className="diag-check-detail">
|
||
|
|
Album lists and photo metadata from Immich. Clearing it makes the next page
|
||
|
|
views slower, nothing else.
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="diag-check-side">
|
||
|
|
<button
|
||
|
|
className="admin-btn admin-btn-sm"
|
||
|
|
onClick={clearCache}
|
||
|
|
disabled={clearing}
|
||
|
|
>
|
||
|
|
<Icons.IconRefresh size={14} /> {clearing ? 'Clearing…' : 'Clear cache'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<BackupManagerModal
|
||
|
|
isOpen={showBackups}
|
||
|
|
onClose={() => setShowBackups(false)}
|
||
|
|
onRestoreSuccess={fetchStatus}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|