296 lines
9.7 KiB
TypeScript
296 lines
9.7 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react';
|
|
|
|
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
|
import { getDevDiagnostics, runDevStorageCheck } from '../../lib/api';
|
|
import type { DevDiagnostics, DevStorageCheck } from '../../types/dev';
|
|
|
|
interface UploadErrorLog {
|
|
at: string;
|
|
filename: string;
|
|
message: string;
|
|
}
|
|
|
|
export default function DevPage() {
|
|
const [diagnostics, setDiagnostics] = useState<DevDiagnostics | null>(null);
|
|
const [storageCheck, setStorageCheck] = useState<DevStorageCheck | null>(null);
|
|
const [uploadError, setUploadError] = useState<UploadErrorLog | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [checking, setChecking] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
async function refresh() {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
setDiagnostics(await getDevDiagnostics());
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : 'Could not load diagnostics.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
try {
|
|
const raw = window.localStorage.getItem('studio:last-upload-error');
|
|
if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog);
|
|
} catch {
|
|
setUploadError(null);
|
|
}
|
|
}, []);
|
|
|
|
async function checkStorage() {
|
|
setChecking(true);
|
|
try {
|
|
setStorageCheck(await runDevStorageCheck());
|
|
} catch (reason) {
|
|
setStorageCheck({
|
|
ok: false,
|
|
step: 'api',
|
|
error: reason instanceof Error ? reason.message : 'Storage check failed.',
|
|
});
|
|
} finally {
|
|
setChecking(false);
|
|
void refresh();
|
|
}
|
|
}
|
|
|
|
const browser = {
|
|
origin: window.location.origin,
|
|
online: navigator.onLine,
|
|
xhr: typeof XMLHttpRequest !== 'undefined',
|
|
fileApi: typeof File !== 'undefined' && typeof FileReader !== 'undefined',
|
|
secureContext: window.isSecureContext,
|
|
};
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="studio-page dev-page">
|
|
<header className="studio-page__header studio-page__header--compact">
|
|
<div>
|
|
<p className="studio-kicker">Workspace / diagnostics</p>
|
|
<h1 className="studio-display">Dev Console</h1>
|
|
<p className="studio-page__lede">Local runtime diagnostics and storage checks.</p>
|
|
</div>
|
|
<div className="dev-page__actions">
|
|
<button
|
|
className="editor-button editor-button--quiet"
|
|
type="button"
|
|
onClick={() => void refresh()}
|
|
disabled={loading}
|
|
>
|
|
Refresh
|
|
</button>
|
|
<button
|
|
className="editor-button editor-button--accent"
|
|
type="button"
|
|
onClick={() => void checkStorage()}
|
|
disabled={checking}
|
|
>
|
|
{checking ? 'Checking...' : 'Test storage'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
|
<div className="dev-grid">
|
|
<DevPanel title="Runtime" eyebrow="01 / API">
|
|
{loading || !diagnostics ? (
|
|
<DevLoading />
|
|
) : (
|
|
<>
|
|
<DevRow label="Environment" value={diagnostics.environment} />
|
|
<DevRow label="API time" value={diagnostics.now} />
|
|
<DevRow label="Account" value={diagnostics.user.email} />
|
|
<DevRow
|
|
label="Session cookie"
|
|
value={diagnostics.http.cookieSecure ? 'Secure' : 'Local / insecure'}
|
|
/>
|
|
</>
|
|
)}
|
|
</DevPanel>
|
|
<DevPanel title="Database" eyebrow="02 / Persistence">
|
|
{loading || !diagnostics ? (
|
|
<DevLoading />
|
|
) : (
|
|
<>
|
|
<DevStatus
|
|
label={diagnostics.database.driver}
|
|
ok={diagnostics.database.connected}
|
|
/>
|
|
<DevRow
|
|
label="Connection"
|
|
value={
|
|
diagnostics.database.connected
|
|
? 'Reachable'
|
|
: diagnostics.database.error || 'Unavailable'
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</DevPanel>
|
|
<DevPanel title="Object storage" eyebrow="03 / MinIO">
|
|
{loading || !diagnostics ? (
|
|
<DevLoading />
|
|
) : (
|
|
<>
|
|
<DevStatus
|
|
label={diagnostics.storage.provider}
|
|
ok={diagnostics.storage.reachable}
|
|
/>
|
|
<DevRow label="Endpoint" value={diagnostics.storage.endpoint} />
|
|
<DevRow label="Bucket" value={diagnostics.storage.bucket} />
|
|
<DevRow label="Protocol" value={diagnostics.storage.secure ? 'HTTPS' : 'HTTP'} />
|
|
{diagnostics.storage.error && (
|
|
<p className="dev-panel__error">{diagnostics.storage.error}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</DevPanel>
|
|
<DevPanel title="Browser" eyebrow="04 / Client">
|
|
<DevRow label="Origin" value={browser.origin} />
|
|
<DevRow label="Network" value={browser.online ? 'Online' : 'Offline'} />
|
|
<DevRow label="XMLHttpRequest" value={browser.xhr ? 'Available' : 'Unavailable'} />
|
|
<DevRow label="File API" value={browser.fileApi ? 'Available' : 'Unavailable'} />
|
|
<DevRow
|
|
label="Secure context"
|
|
value={browser.secureContext ? 'Yes' : 'No (normal for local HTTP)'}
|
|
/>
|
|
</DevPanel>
|
|
</div>
|
|
<section className="dev-panel dev-panel--wide">
|
|
<div className="dev-panel__heading">
|
|
<p className="studio-kicker">05 / Upload path</p>
|
|
<h2>What happens when you choose a file.</h2>
|
|
</div>
|
|
<div className="dev-flow">
|
|
<span>
|
|
01 <strong>API URL</strong>
|
|
<small>Creates media metadata</small>
|
|
</span>
|
|
<i>→</i>
|
|
<span>
|
|
02 <strong>Presigned PUT</strong>
|
|
<small>Browser to MinIO</small>
|
|
</span>
|
|
<i>→</i>
|
|
<span>
|
|
03 <strong>Complete</strong>
|
|
<small>Stat object and queue processing</small>
|
|
</span>
|
|
<i>→</i>
|
|
<span>
|
|
04 <strong>Ready</strong>
|
|
<small>Preview becomes visible</small>
|
|
</span>
|
|
</div>
|
|
<p className="dev-hint">
|
|
If an upload stops at 0%, the failure is usually the browser reaching the presigned
|
|
MinIO origin. Check that the browser origin below is listed in the MinIO bucket CORS
|
|
rule.
|
|
</p>
|
|
{diagnostics && (
|
|
<div className="dev-cors">
|
|
<span>Effective browser origins</span>
|
|
{diagnostics.http.corsOrigins.map((origin) => (
|
|
<code key={origin}>{origin}</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
<section className="dev-panel dev-panel--wide">
|
|
<div className="dev-panel__heading">
|
|
<p className="studio-kicker">06 / Last client failure</p>
|
|
<h2>Recent upload signal.</h2>
|
|
</div>
|
|
{uploadError ? (
|
|
<div className="dev-last-error">
|
|
<span className="dev-last-error__mark">!</span>
|
|
<div>
|
|
<strong>{uploadError.filename}</strong>
|
|
<p>{uploadError.message}</p>
|
|
<small>{new Date(uploadError.at).toLocaleString()}</small>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
window.localStorage.removeItem('studio:last-upload-error');
|
|
setUploadError(null);
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<p className="dev-empty">
|
|
No client-side upload failures have been recorded in this browser.
|
|
</p>
|
|
)}
|
|
{storageCheck && (
|
|
<div className={`dev-check-result ${storageCheck.ok ? 'is-ok' : 'is-failed'}`}>
|
|
<strong>{storageCheck.ok ? 'Storage check passed' : 'Storage check failed'}</strong>
|
|
<span>
|
|
{storageCheck.ok
|
|
? `${storageCheck.bytes} bytes / ${storageCheck.contentType}`
|
|
: `${storageCheck.step}: ${storageCheck.error}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</section>
|
|
<details className="dev-raw">
|
|
<summary>Show raw diagnostics JSON</summary>
|
|
<pre>{diagnostics ? JSON.stringify(diagnostics, null, 2) : 'No response yet.'}</pre>
|
|
</details>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
function DevPanel({
|
|
title,
|
|
eyebrow,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
eyebrow: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="dev-panel">
|
|
<div className="dev-panel__heading">
|
|
<p className="studio-kicker">{eyebrow}</p>
|
|
<h2>{title}</h2>
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DevRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="dev-row">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DevStatus({ label, ok }: { label: string; ok: boolean }) {
|
|
return (
|
|
<div className={`dev-status ${ok ? 'is-ok' : 'is-failed'}`}>
|
|
<i /> <strong>{label}</strong>
|
|
<span>{ok ? 'reachable' : 'unavailable'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DevLoading() {
|
|
return (
|
|
<div className="dev-loading">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
);
|
|
}
|