feat: add the dashboard
Current state at the top, the panel as it stands beside what is known about the device, the next seven days resolved with their exceptions, and alerts when something needs attention. There is no "push now" button, because in BYOS there is nothing to push: the panel sleeps on battery and fetches when it wakes. Pretending otherwise would be the most misleading control on the page. It says so in plain words instead, and shows when the device is next due back. Everything about the device is inference from one timestamp and one interval, so it lives in a tested module rather than in a template. A panel is only called overdue once it has missed its slot by half its interval again: firmware wake-ups drift, and an alert that cries wolf every cycle is an alert nobody reads. Battery falls back to a voltage estimate, labelled as an estimate, because "about a third left" is the only thing anyone acts on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
+181
-53
@@ -1,70 +1,198 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { deviceHealth, humaniseSeconds } from '@/lib/device/health';
|
||||
import { toCivilDate } from '@/lib/schedule/civil';
|
||||
import { loadScheduleContext, loadSettings } from '@/lib/schedule/context';
|
||||
import { formatDateFr, formatHoursText, formatNextChangeFr } from '@/lib/schedule/format';
|
||||
import { getCurrentStatus, resolveWeek } from '@/lib/schedule/resolver';
|
||||
|
||||
import ScreenPreview from './ScreenPreview';
|
||||
|
||||
export const metadata = { title: 'Tableau de bord — ITA ITO' };
|
||||
|
||||
export default async function AdminHome() {
|
||||
const session = await auth();
|
||||
const user = session?.user;
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const now = new Date();
|
||||
const [session, settings] = await Promise.all([auth(), loadSettings()]);
|
||||
const context = await loadScheduleContext(now, settings);
|
||||
|
||||
const status = getCurrentStatus(now, context);
|
||||
const week = resolveWeek(now, context);
|
||||
const today = toCivilDate(now, settings.timezone);
|
||||
|
||||
const [device, failedTranslations, holidaySync] = await Promise.all([
|
||||
prisma.device.findFirst({ where: { isActive: true }, orderBy: { lastSeenAt: 'desc' } }),
|
||||
prisma.message.count({ where: { translationStatus: 'ERROR' } }),
|
||||
prisma.syncState.findUnique({ where: { key: 'holidays' } }),
|
||||
]);
|
||||
|
||||
const health = device ? deviceHealth(device, now) : null;
|
||||
const isOpen = status.status === 'OPEN' || status.status === 'CLOSING_SOON';
|
||||
|
||||
const alerts: { text: string; href?: string }[] = [];
|
||||
if (!device) {
|
||||
alerts.push({ text: 'Aucun écran appairé. Suivez la procédure d’appairage du README.' });
|
||||
} else if (health?.neverSeen) {
|
||||
alerts.push({ text: 'L’écran est enregistré mais ne s’est jamais manifesté.' });
|
||||
} else if (health?.isOverdue) {
|
||||
const silentFor = humaniseSeconds(secondsSince(device.lastSeenAt, now));
|
||||
alerts.push({
|
||||
text: `L’écran n’a pas donné signe de vie ${silentFor}. Vérifiez la batterie et le Wi-Fi.`,
|
||||
});
|
||||
}
|
||||
if (failedTranslations > 0) {
|
||||
alerts.push({
|
||||
text: `${failedTranslations} message${failedTranslations > 1 ? 's' : ''} sans traduction anglaise.`,
|
||||
href: '/admin/messages',
|
||||
});
|
||||
}
|
||||
if (holidaySync?.lastErrorAt) {
|
||||
alerts.push({
|
||||
text: `La synchronisation des jours fériés a échoué : ${holidaySync.lastError ?? 'raison inconnue'}.`,
|
||||
href: '/admin/feries',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-4 py-10">
|
||||
<main className="mx-auto max-w-4xl px-4 py-10">
|
||||
<h1 className="text-2xl">Tableau de bord</h1>
|
||||
<p className="mt-2 text-[var(--ink-muted)]">
|
||||
Connecté en tant que {user?.email} ({isAdmin ? 'administrateur' : 'lecture seule'}).
|
||||
</p>
|
||||
|
||||
{!isAdmin ? <ReadOnlyExplanation groups={user?.groups ?? []} expected={user?.adminGroup ?? ''} /> : null}
|
||||
{alerts.length > 0 ? (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.text}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--danger)] bg-[var(--danger-tint)] px-4 py-3 text-sm text-[var(--danger)]"
|
||||
>
|
||||
{alert.href ? (
|
||||
<Link href={alert.href} className="underline-offset-2 hover:underline">
|
||||
{alert.text}
|
||||
</Link>
|
||||
) : (
|
||||
alert.text
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<section className="mt-6 rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<span
|
||||
className={`rounded-[var(--radius-sm)] px-3 py-1 text-lg font-medium ${
|
||||
isOpen
|
||||
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]'
|
||||
}`}
|
||||
>
|
||||
{isOpen ? 'Ouvert' : 'Fermé'}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{status.today.slots.length > 0 ? formatHoursText(status.today.slots) : 'Fermé aujourd’hui'}
|
||||
</span>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
{formatNextChangeFr(
|
||||
status.nextChangeAt
|
||||
? { kind: isOpen ? 'CLOSES' : 'OPENS', ...status.nextChangeAt }
|
||||
: null,
|
||||
today,
|
||||
) || 'Aucune réouverture programmée'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--ink-muted)]">{formatDateFr(today)}</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-[1.2fr_1fr]">
|
||||
<section>
|
||||
<h2 className="text-lg">L’écran en ce moment</h2>
|
||||
<ScreenPreview className="mt-3" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg">Appareil</h2>
|
||||
{device && health ? (
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<Row label="Identifiant">{device.friendlyId}</Row>
|
||||
<Row label="Dernier contact">
|
||||
{device.lastSeenAt ? humaniseSeconds(secondsSince(device.lastSeenAt, now)) : 'jamais'}
|
||||
</Row>
|
||||
<Row label="Prochain réveil">
|
||||
{health.secondsUntilWake === null
|
||||
? 'inconnu'
|
||||
: health.secondsUntilWake >= 0
|
||||
? humaniseSeconds(health.secondsUntilWake)
|
||||
: 'attendu, en retard'}
|
||||
</Row>
|
||||
<Row label="Batterie">
|
||||
{health.batteryPercent === null
|
||||
? 'inconnue'
|
||||
: `environ ${health.batteryPercent}%${device.percentCharged === null ? ' (estimée)' : ''}`}
|
||||
</Row>
|
||||
<Row label="Wi-Fi">
|
||||
{health.signal === null
|
||||
? 'inconnu'
|
||||
: { good: 'bon', fair: 'moyen', poor: 'faible' }[health.signal]}
|
||||
</Row>
|
||||
<Row label="Firmware">{device.fwVersion ?? 'inconnu'}</Row>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[var(--ink-muted)]">Aucun écran appairé.</p>
|
||||
)}
|
||||
|
||||
<p className="mt-4 rounded-[var(--radius-md)] bg-[var(--surface-muted)] px-3 py-2 text-sm text-[var(--ink-muted)]">
|
||||
L’écran fonctionne sur batterie et dort entre deux réveils : une modification
|
||||
n’apparaît qu’au réveil suivant. Rien ne peut lui être poussé.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-lg">Les sept prochains jours</h2>
|
||||
<ul className="mt-3 divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
|
||||
{week.map((day, index) => (
|
||||
<li key={day.date} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2.5">
|
||||
<span className={`min-w-44 ${index === 0 ? 'font-medium' : ''}`}>
|
||||
{formatDateFr(day.date)}
|
||||
{index === 0 ? ' (aujourd’hui)' : ''}
|
||||
</span>
|
||||
<span className={day.isOpen ? '' : 'text-[var(--ink-muted)]'}>
|
||||
{day.slots.length > 0 ? formatHoursText(day.slots) : 'Fermé'}
|
||||
</span>
|
||||
{day.isException ? (
|
||||
<span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
|
||||
{day.noteFr ?? 'exception'}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-sm text-[var(--ink-muted)]">
|
||||
<Link href="/admin/exceptions" className="underline-offset-2 hover:underline">
|
||||
Modifier les horaires d’aujourd’hui
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{session?.user?.role !== 'admin' ? (
|
||||
<p className="mt-8 text-sm text-[var(--ink-muted)]">
|
||||
Compte en lecture seule : aucune modification n’est possible.
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only account has exactly two causes, fixed in completely different
|
||||
* places: either the identity provider sent no groups at all, or it sent
|
||||
* groups that do not include the one that grants write access. Saying which
|
||||
* turns a support conversation into a thirty-second fix.
|
||||
*/
|
||||
function ReadOnlyExplanation({ groups, expected }: { groups: string[]; expected: string }) {
|
||||
const claimMissing = groups.length === 0;
|
||||
/** Negative, so `humaniseSeconds` phrases it as "il y a …". */
|
||||
function secondsSince(moment: Date | null, now: Date): number {
|
||||
return moment ? Math.round((moment.getTime() - now.getTime()) / 1000) : 0;
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-8 max-w-2xl rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-base">Pourquoi ce compte est-il en lecture seule ?</h2>
|
||||
|
||||
<dl className="mt-4 space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Groupe donnant l’accès en écriture</dt>
|
||||
<dd className="mt-0.5 font-mono">{expected || '(non configuré)'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Groupes reçus d’Authentik</dt>
|
||||
<dd className="mt-0.5 font-mono">
|
||||
{claimMissing ? 'aucun — le claim « groups » est absent' : groups.join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="mt-4 text-sm text-[var(--ink-muted)]">
|
||||
{claimMissing ? (
|
||||
<>
|
||||
Authentik n’envoie aucun groupe. Dans le provider OAuth2/OpenID, vérifiez que le scope{' '}
|
||||
<span className="font-mono">profile</span> est bien sélectionné et que{' '}
|
||||
<em>Include claims in id_token</em> est activé.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authentik envoie bien des groupes, mais pas celui attendu. Ajoutez ce compte au groupe{' '}
|
||||
<span className="font-mono">{expected}</span>, ou corrigez{' '}
|
||||
<span className="font-mono">AUTHENTIK_ADMIN_GROUP</span> pour qu’il corresponde à l’un
|
||||
des groupes ci-dessus.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-sm text-[var(--ink-muted)]">
|
||||
Le rôle est fixé à la connexion : après correction, déconnectez-vous et reconnectez-vous.
|
||||
</p>
|
||||
</section>
|
||||
<div className="flex justify-between gap-4 border-b border-[var(--line)] pb-2">
|
||||
<dt className="text-[var(--ink-muted)]">{label}</dt>
|
||||
<dd className="text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user