init
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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('northline: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">
|
||||
The <em>workbench.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
Useful, non-secret signals for local development and upload debugging.
|
||||
</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('northline: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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user