mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
feat(mobile): bespoke phone layouts for dashboard, fleet, schedules, and settings (#1330)
* feat(mobile): masthead-led dashboard and 5-tab bottom nav on phones On phones (below the md breakpoint) the dashboard now renders a bespoke, masthead-led layout instead of the reflowed desktop workspace: - A status masthead leads with the overall system-health verdict, the node, and a live summary (stack counts, last sync, a "metrics stale" marker when polling stops). - A CPU hero card with a sparkline, then a memory / disk / network strip with threshold-colored bars, then a tappable stack-health list. - The bottom tab bar gains a Home tab (Home / Stacks / Fleet / Sched / Settings); the global top bar is dropped on this screen, with notifications and a "more" menu rehomed into the masthead. The health-verdict logic is extracted into a shared helper so the phone masthead and the desktop health bar read from one source, with unit tests. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged (verified against the desktop snapshot gate). * feat(mobile): bespoke fleet glance and node detail on phones On phones (below the md breakpoint) the Fleet view now renders a bespoke, masthead-led layout instead of the reflowed desktop workspace: - A fleet masthead leads with the overall fleet-health verdict and a running / cpu / mem summary band, then a list of node cards. The local node is marked with a cyan rail and a "you are here" tag; offline nodes are dimmed. - Tapping a node opens a full-screen node detail: state pill, resource bars (cpu / mem / disk), the stacks running on that node, and an Inspect action that switches to the node. Operators with the right permissions also get a Drain (cordon) action. - The screen polls the fleet overview every 30 seconds; the global top bar is dropped here, with notifications and a "more" menu in the masthead. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged. * feat(mobile): bespoke schedules and settings screens on phones On phones (below the md breakpoint) Schedules and Settings now render bespoke, masthead-led layouts instead of the reflowed desktop workspace: - Schedules: a "next up" glance leading with the next run time and countdown, then upcoming runs grouped by day with a per-action status dot and target. It is read-only on mobile; creating and editing schedules stays on desktop. - Settings: a grouped-card list of every reachable section; tapping one opens it full-screen with a back affordance and a section masthead. The section content itself is the same as on desktop. The settings section switch, lazy-loaded section chunks, and tier gating are moved into a shared component so the desktop and mobile screens render the same section content from one place. The global top bar is dropped on both screens, with notifications and a "more" menu in the masthead. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged. * fix(mobile): show notifications and more-menu on the stack detail header The full-screen stack detail on phones drops the global top bar, but its header was missing the notifications bell and the "more" navigation menu that the other mobile screens carry in their masthead, leaving no way to reach notifications or other destinations while viewing a stack. Render the same header-actions cluster in the detail header (and the loading placeholder), next to the back affordance. Desktop is unaffected.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useDashboardData } from '@/components/dashboard';
|
||||
import { deriveHealth } from '@/components/dashboard/deriveHealth';
|
||||
import type { HealthLevel, NotificationItem, StackCpuSeries, StackStatusEntry } from '@/components/dashboard/types';
|
||||
import { Bar, Kicker, Masthead, MSparkline, SectionHead, StateDot } from './mobile-ui';
|
||||
|
||||
interface MobileDashboardProps {
|
||||
notifications: NotificationItem[];
|
||||
/** Notification bell + more-menu cluster for the masthead right slot. */
|
||||
headerActions: ReactNode;
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
onViewAllStacks: () => void;
|
||||
}
|
||||
|
||||
const SPARK_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
const LEVEL_LABEL: Record<HealthLevel, string> = { healthy: 'Healthy', degraded: 'Degraded', critical: 'Critical' };
|
||||
const LEVEL_TONE: Record<HealthLevel, 'success' | 'warning' | 'destructive'> = {
|
||||
healthy: 'success',
|
||||
degraded: 'warning',
|
||||
critical: 'destructive',
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const c = Math.max(0, ms);
|
||||
if (c < 60_000) return `${Math.round(c / 1000)}s`;
|
||||
if (c < 3_600_000) return `${Math.round(c / 60_000)}m`;
|
||||
return `${Math.round(c / 3_600_000)}h`;
|
||||
}
|
||||
|
||||
type RowState = 'healthy' | 'warn' | 'error';
|
||||
|
||||
function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState {
|
||||
if (status === 'exited') return 'error';
|
||||
if (peakCpu >= 90) return 'error';
|
||||
if (peakCpu >= 80) return 'warn';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
const ROW_TINT: Record<RowState, string> = {
|
||||
healthy: '',
|
||||
warn: 'bg-warning-muted',
|
||||
error: 'bg-destructive-muted',
|
||||
};
|
||||
const ROW_TONE: Record<RowState, 'success' | 'warning' | 'destructive'> = {
|
||||
healthy: 'success',
|
||||
warn: 'warning',
|
||||
error: 'destructive',
|
||||
};
|
||||
const ROW_STROKE: Record<RowState, string> = {
|
||||
healthy: 'var(--brand)',
|
||||
warn: 'var(--warning)',
|
||||
error: 'var(--destructive)',
|
||||
};
|
||||
|
||||
// One labeled metric cell inside the 3-up strip (memory / disk / network).
|
||||
function StripCell({ label, value, bar }: { label: string; value: string; bar?: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 px-[13px] py-3">
|
||||
<Kicker>{label}</Kicker>
|
||||
<div className="mt-1.5 font-mono tabular-nums text-[17px] leading-none text-stat-value truncate">{value}</div>
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileDashboard({ notifications, headerActions, onNavigateToStack, onViewAllStacks }: MobileDashboardProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
const locality = activeNode?.type === 'remote' ? 'remote' : 'local';
|
||||
|
||||
// Re-render every few seconds so the "sync Xs" freshness label advances
|
||||
// without a parent refetch, mirroring the desktop HealthStatusBar ticker.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const { level } = useMemo(
|
||||
() => deriveHealth(data.stats, data.systemStats, notifications),
|
||||
[data.stats, data.systemStats, notifications],
|
||||
);
|
||||
|
||||
const cpuVal = parseFloat(data.systemStats?.cpu.usage || '0');
|
||||
const ramVal = parseFloat(data.systemStats?.memory.usagePercent || '0');
|
||||
const diskVal = parseFloat(data.systemStats?.disk?.usagePercent || '0');
|
||||
const netPerSec = (data.systemStats?.network?.rxSec ?? 0) + (data.systemStats?.network?.txSec ?? 0);
|
||||
|
||||
const cpuHistory = data.cpuHistory;
|
||||
const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0;
|
||||
const cpuAvg = cpuHistory.length > 0 ? cpuHistory.reduce((s, v) => s + v, 0) / cpuHistory.length : 0;
|
||||
const cpuPeakLabel = useMemo(() => {
|
||||
if (cpuHistory.length === 0 || data.historyEndAt === null) return null;
|
||||
const peakIndex = cpuHistory.indexOf(cpuPeak);
|
||||
if (peakIndex < 0) return null;
|
||||
const bucketMs = SPARK_WINDOW_MS / cpuHistory.length;
|
||||
const ts = data.historyEndAt - (cpuHistory.length - 1 - peakIndex) * bucketMs;
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}, [cpuHistory, cpuPeak, data.historyEndAt]);
|
||||
|
||||
const healthRows = useMemo(() => {
|
||||
const list = Object.entries(data.stackStatuses).map(([file, entry]) => {
|
||||
const name = file.replace(/\.(ya?ml)$/, '');
|
||||
const series: StackCpuSeries | undefined = data.stackCpuSeries[name];
|
||||
const peakCpu = series?.peakValue ?? 0;
|
||||
return {
|
||||
file,
|
||||
name,
|
||||
status: entry.status,
|
||||
cpu: series?.latestValue ?? 0,
|
||||
points: series?.points ?? [],
|
||||
peakCpu,
|
||||
state: classifyRow(entry.status, peakCpu),
|
||||
};
|
||||
});
|
||||
const order: Record<RowState, number> = { error: 0, warn: 1, healthy: 2 };
|
||||
list.sort((a, b) => order[a.state] - order[b.state] || b.peakCpu - a.peakCpu);
|
||||
return list;
|
||||
}, [data.stackStatuses, data.stackCpuSeries]);
|
||||
|
||||
const visibleRows = healthRows.slice(0, 6);
|
||||
const stackCount = healthRows.length;
|
||||
const upCount = healthRows.filter(r => r.status === 'running').length;
|
||||
const downCount = healthRows.filter(r => r.status === 'exited').length;
|
||||
const syncLabel = data.lastSyncAt ? `sync ${formatAgo(now - data.lastSyncAt)}` : 'connecting…';
|
||||
|
||||
const cpuSub = cpuHistory.length > 0
|
||||
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
|
||||
: 'collecting metrics…';
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker={`${activeNodeName} · ${locality}`}
|
||||
state={LEVEL_LABEL[level]}
|
||||
stateTone={LEVEL_TONE[level]}
|
||||
live={level !== 'healthy' || data.metricsStale}
|
||||
meta={
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
{/* When metrics polling has died, "sync Xs" would falsely imply
|
||||
liveness, so swap it for a stale marker and a warning chip
|
||||
(mirrors the desktop HealthStatusBar badge). */}
|
||||
<span>{`${stackCount} stacks · ${upCount} up · ${downCount} dn · ${data.metricsStale ? 'metrics paused' : syncLabel}`}</span>
|
||||
{data.metricsStale ? (
|
||||
<span className="rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-warning">
|
||||
metrics stale
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
right={headerActions}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
|
||||
{/* CPU hero */}
|
||||
<div className="rounded-[12px] border border-card-border border-t-card-border-top bg-card px-[15px] pt-[13px] pb-2 shadow-card-bevel">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Kicker>{`cpu${data.systemStats ? ` · ${data.systemStats.cpu.cores} cores` : ''}`}</Kicker>
|
||||
<span className="font-mono tabular-nums text-[30px] leading-none tracking-[-0.02em] text-stat-value">
|
||||
{data.systemStats ? `${cpuVal.toFixed(0)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-[-3px] mb-1 mt-1.5">
|
||||
<MSparkline values={cpuHistory} height={46} peak={false} />
|
||||
</div>
|
||||
<div className="font-mono text-[10.5px] text-stat-subtitle">{cpuSub}</div>
|
||||
</div>
|
||||
|
||||
{/* mem / disk / net 3-up */}
|
||||
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<StripCell
|
||||
label="mem"
|
||||
value={data.systemStats ? `${ramVal.toFixed(0)}%` : '--'}
|
||||
bar={data.systemStats ? <Bar pct={ramVal} /> : undefined}
|
||||
/>
|
||||
<StripCell
|
||||
label="disk"
|
||||
value={data.systemStats?.disk ? `${diskVal.toFixed(0)}%` : '--'}
|
||||
bar={data.systemStats?.disk ? <Bar pct={diskVal} /> : undefined}
|
||||
/>
|
||||
<StripCell
|
||||
label="net"
|
||||
value={data.systemStats?.network ? `${formatBytes(netPerSec)}/s` : '--'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* stack health */}
|
||||
<div>
|
||||
<SectionHead right={<button type="button" onClick={onViewAllStacks} className="text-brand">view all →</button>}>
|
||||
stack health
|
||||
</SectionHead>
|
||||
{visibleRows.length === 0 ? (
|
||||
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No stacks yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{visibleRows.map(row => (
|
||||
<button
|
||||
key={row.file}
|
||||
type="button"
|
||||
onClick={() => onNavigateToStack(row.file)}
|
||||
className={`flex min-h-11 items-center gap-2.5 rounded-[7px] px-2.5 py-[9px] text-left ${ROW_TINT[row.state]}`}
|
||||
>
|
||||
<StateDot tone={ROW_TONE[row.state]} size={7} glow={row.state !== 'healthy'} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono text-[13px] text-stat-value">{row.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-stat-icon">{activeNodeName}</span>
|
||||
</span>
|
||||
<span className="block h-[18px] w-[60px] shrink-0">
|
||||
{row.points.length > 1 ? (
|
||||
<MSparkline values={row.points} height={18} color={ROW_STROKE[row.state]} peak={false} />
|
||||
) : (
|
||||
<span className="block h-full w-full border-b border-dashed border-hairline" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-[34px] shrink-0 text-right font-mono tabular-nums text-[12px] ${row.cpu >= 80 ? 'text-warning' : 'text-stat-subtitle'}`}
|
||||
>
|
||||
{`${row.cpu.toFixed(0)}%`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui';
|
||||
import type { Tone as UiTone } from './mobile-ui';
|
||||
|
||||
interface MobileFleetProps {
|
||||
headerActions: ReactNode;
|
||||
/** Switch the active node and drop to its stack list. */
|
||||
onInspectNode: (nodeId: number) => void;
|
||||
/** Switch the active node and open a specific stack on it. */
|
||||
onInspectStack: (nodeId: number, stackName: string) => void;
|
||||
}
|
||||
|
||||
// Node health is a strict subset of the primitive tones (never brand-colored).
|
||||
// Deriving it keeps the subset compiler-enforced if mobile-ui's tones change.
|
||||
type Tone = Exclude<UiTone, 'brand'>;
|
||||
|
||||
function nodeTone(node: FleetNode): Tone {
|
||||
if (node.status !== 'online') return 'destructive';
|
||||
if (isCritical(node)) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const c = Math.max(0, ms);
|
||||
if (c < 60_000) return `${Math.round(c / 1000)}s`;
|
||||
if (c < 3_600_000) return `${Math.round(c / 60_000)}m`;
|
||||
return `${Math.round(c / 3_600_000)}h`;
|
||||
}
|
||||
|
||||
// Fetch + poll the fleet overview (same endpoint the desktop fleet uses).
|
||||
function useMobileFleet() {
|
||||
const [nodes, setNodes] = useState<FleetNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await apiFetch('/fleet/overview', { localOnly: true, signal: controller.signal });
|
||||
if (res.ok) {
|
||||
setNodes(await res.json() as FleetNode[]);
|
||||
setLastSyncAt(Date.now());
|
||||
} else {
|
||||
// Leave the stale data and let the masthead "last sync" age visibly
|
||||
// rather than toast on every failed background poll, but log so a
|
||||
// wedged poll is traceable.
|
||||
console.error('Fleet overview poll failed:', res.status);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.error('Failed to fetch fleet overview:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// fetchOverview sets state only after an await, in a later tick, so it does
|
||||
// not cause the synchronous cascading render this rule guards against; the
|
||||
// rule can't follow the call through the async boundary and flags it here.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchOverview();
|
||||
const id = setInterval(() => void fetchOverview(), 30_000);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchOverview]);
|
||||
|
||||
return { nodes, loading, lastSyncAt, refetch: fetchOverview };
|
||||
}
|
||||
|
||||
// One labeled metric cell in the masthead band / node card.
|
||||
function StatCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 px-3 py-2.5 text-center">
|
||||
<div className="font-mono tabular-nums text-[17px] leading-none text-stat-value truncate">{value}</div>
|
||||
<div className="mt-1"><Kicker>{label}</Kicker></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) {
|
||||
const tone = nodeTone(node);
|
||||
const local = node.type === 'local';
|
||||
const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={`relative w-full overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card text-left shadow-card-bevel ${node.status === 'online' ? '' : 'opacity-60'}`}
|
||||
>
|
||||
{local ? <span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" /> : null}
|
||||
<div className="flex items-center gap-2.5 px-[13px] pb-2.5 pt-3">
|
||||
<StateDot tone={tone} size={7} glow pulse={local} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[14px] text-stat-value">{node.name}</span>
|
||||
{local ? (
|
||||
<span className="rounded-[5px] bg-brand/[0.09] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-brand">
|
||||
you are here
|
||||
</span>
|
||||
) : null}
|
||||
{isActive && !local ? (
|
||||
<span className="rounded-[5px] bg-brand/[0.09] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-brand">
|
||||
active
|
||||
</span>
|
||||
) : null}
|
||||
<Kicker className={tone === 'destructive' ? 'text-destructive' : tone === 'warning' ? 'text-warning' : 'text-stat-subtitle'}>
|
||||
{node.cordoned ? 'cordoned' : stateLabel}
|
||||
</Kicker>
|
||||
</div>
|
||||
<div className="flex items-stretch divide-x divide-hairline border-t border-hairline">
|
||||
<StatCell label="stacks" value={`${node.stacks?.length ?? 0}`} />
|
||||
<StatCell label="cpu" value={node.status === 'online' && node.systemStats ? `${getNodeCpu(node).toFixed(0)}%` : '--'} />
|
||||
<StatCell label="mem" value={node.status === 'online' && node.systemStats ? `${getNodeMem(node).toFixed(0)}%` : '--'} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// One labeled resource bar in the node detail.
|
||||
function ResourceRow({ label, pct, detail }: { label: string; pct: number; detail: string }) {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Kicker>{label}</Kicker>
|
||||
<span className="font-mono tabular-nums text-[12px] text-stat-subtitle">{detail}</span>
|
||||
</div>
|
||||
<Bar pct={pct} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDetail({
|
||||
node,
|
||||
now,
|
||||
onBack,
|
||||
onInspectNode,
|
||||
onInspectStack,
|
||||
onCordonChange,
|
||||
}: {
|
||||
node: FleetNode;
|
||||
now: number;
|
||||
onBack: () => void;
|
||||
onInspectNode: (nodeId: number) => void;
|
||||
onInspectStack: (nodeId: number, stackName: string) => void;
|
||||
onCordonChange: () => void;
|
||||
}) {
|
||||
const { isPaid } = useLicense();
|
||||
const { can } = useAuth();
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const tone = nodeTone(node);
|
||||
const online = node.status === 'online';
|
||||
const lastSeen = node.last_successful_contact ?? node.pilot_last_seen ?? null;
|
||||
const stacks = node.stacks ?? [];
|
||||
|
||||
const handleCordon = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (node.cordoned) {
|
||||
await uncordonNode(node.id);
|
||||
toast.success(`Uncordoned ${node.name}`);
|
||||
} else {
|
||||
await cordonNode(node.id, null);
|
||||
toast.success(`Cordoned ${node.name}`);
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
onCordonChange();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update cordon state');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="px-2 pt-1">
|
||||
<BackChip label="Fleet" onClick={onBack} />
|
||||
</div>
|
||||
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
|
||||
<span aria-hidden className={`absolute left-0 top-1 bottom-[15px] w-[3px] ${tone === 'destructive' ? 'bg-destructive' : tone === 'warning' ? 'bg-warning' : 'bg-brand'}`} />
|
||||
<div className="mb-1"><Kicker>{`fleet › node › ${node.name}`}</Kicker></div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="min-w-0 truncate font-display italic text-[30px] leading-[34px] text-stat-value">{node.name}</span>
|
||||
<StatePill tone={tone} live={!online}>{node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'}</StatePill>
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">
|
||||
{`${node.type} · ${stacks.length} stacks${lastSeen ? ` · last seen ${formatAgo(now - lastSeen)}` : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px]">
|
||||
<div className="flex gap-2">
|
||||
<MBtn kind="primary" full onClick={() => onInspectNode(node.id)}>Inspect</MBtn>
|
||||
{canCordon ? (
|
||||
<MBtn kind="outline" full onClick={() => setConfirmOpen(true)}>
|
||||
{node.cordoned ? 'Uncordon' : 'Drain'}
|
||||
</MBtn>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{online && node.systemStats ? (
|
||||
<div className="mt-[14px]">
|
||||
<SectionHead right="cpu · mem · disk">resources</SectionHead>
|
||||
<ResourceRow label="cpu" pct={getNodeCpu(node)} detail={`${getNodeCpu(node).toFixed(0)}%`} />
|
||||
<ResourceRow
|
||||
label="mem"
|
||||
pct={getNodeMem(node)}
|
||||
detail={`${formatBytes(node.systemStats.memory.used, 1)} / ${formatBytes(node.systemStats.memory.total, 1)}`}
|
||||
/>
|
||||
{node.systemStats.disk ? (
|
||||
<ResourceRow
|
||||
label="disk"
|
||||
pct={getNodeDisk(node)}
|
||||
detail={`${formatBytes(node.systemStats.disk.used, 1)} / ${formatBytes(node.systemStats.disk.total, 1)}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-[14px]">
|
||||
<SectionHead right={`${stacks.length}`}>stacks on node</SectionHead>
|
||||
{stacks.length === 0 ? (
|
||||
<p className="px-1 py-3 font-mono text-[12px] text-stat-subtitle">{online ? 'No stacks on this node.' : 'Node unreachable.'}</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{stacks.map(stack => (
|
||||
<button
|
||||
key={stack}
|
||||
type="button"
|
||||
onClick={() => onInspectStack(node.id, stack)}
|
||||
className="flex min-h-11 items-center gap-2 border-b border-hairline py-2 text-left last:border-b-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-value">{stack.replace(/\.(ya?ml)$/, '')}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-stat-icon" strokeWidth={1.6} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline bg-band px-4 py-2.5">
|
||||
<Kicker>{`${lastSeen ? `last seen ${formatAgo(now - lastSeen)} ago · ` : ''}auto-refreshes every 30s`}</Kicker>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!submitting) setConfirmOpen(open); }}
|
||||
kicker="Federation"
|
||||
title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`}
|
||||
description={node.cordoned
|
||||
? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.'
|
||||
: 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'}
|
||||
confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
||||
confirming={submitting}
|
||||
onConfirm={handleCordon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: MobileFleetProps) {
|
||||
const { nodes, loading, lastSyncAt, refetch } = useMobileFleet();
|
||||
const { activeNode } = useNodes();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const selected = selectedId !== null ? nodes.find(n => n.id === selectedId) ?? null : null;
|
||||
if (selected) {
|
||||
return (
|
||||
<NodeDetail
|
||||
node={selected}
|
||||
now={now}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onInspectNode={onInspectNode}
|
||||
onInspectStack={onInspectStack}
|
||||
onCordonChange={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineNodes = nodes.filter(n => n.status === 'online');
|
||||
const criticalCount = onlineNodes.filter(isCritical).length;
|
||||
const offlineCount = nodes.length - onlineNodes.length;
|
||||
const level = criticalCount > 0 ? 'critical' : offlineCount > 0 ? 'degraded' : 'healthy';
|
||||
const label = level === 'critical' ? 'Critical' : level === 'degraded' ? 'Degraded' : 'Healthy';
|
||||
const tone: Tone = level === 'critical' ? 'destructive' : level === 'degraded' ? 'warning' : 'success';
|
||||
|
||||
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
|
||||
const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
|
||||
const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0;
|
||||
const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0);
|
||||
const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0);
|
||||
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
|
||||
const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker="fleet · overview"
|
||||
state={label}
|
||||
stateTone={tone}
|
||||
live={level !== 'healthy'}
|
||||
meta={`${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'} · ${totalStacks} stacks · ${syncLabel}`}
|
||||
right={headerActions}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
|
||||
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<StatCell label="running" value={`${running}`} />
|
||||
<StatCell label="cpu" value={onlineNodes.length > 0 ? `${avgCpu.toFixed(0)}%` : '--'} />
|
||||
<StatCell label="mem" value={memTotal > 0 ? `${memPct.toFixed(0)}%` : '--'} />
|
||||
</div>
|
||||
|
||||
{loading && nodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No nodes configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{nodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
isActive={activeNode?.id === node.id}
|
||||
onOpen={() => setSelectedId(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
import { Masthead, SectionHead, StateDot } from './mobile-ui';
|
||||
|
||||
interface MobileSchedulesProps {
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
type Tone = 'success' | 'warning' | 'destructive' | 'brand';
|
||||
|
||||
const ACTION_TONE: Record<ScheduledTask['action'], Tone> = {
|
||||
restart: 'brand',
|
||||
update: 'success',
|
||||
scan: 'success',
|
||||
prune: 'warning',
|
||||
snapshot: 'warning',
|
||||
auto_backup: 'brand',
|
||||
auto_stop: 'warning',
|
||||
auto_down: 'destructive',
|
||||
auto_start: 'success',
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ScheduledTask['action'], string> = {
|
||||
restart: 'restart',
|
||||
update: 'update',
|
||||
scan: 'scan',
|
||||
prune: 'prune',
|
||||
snapshot: 'snapshot',
|
||||
auto_backup: 'backup',
|
||||
auto_stop: 'stop',
|
||||
auto_down: 'down',
|
||||
auto_start: 'start',
|
||||
};
|
||||
|
||||
function hhmm(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function relative(ts: number, now: number): string {
|
||||
const diff = ts - now;
|
||||
if (diff <= 0) return 'now';
|
||||
const mins = Math.round(diff / 60_000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const rem = mins % 60;
|
||||
return rem === 0 ? `in ${hours}h` : `in ${hours}h ${rem}m`;
|
||||
}
|
||||
|
||||
function dayLabel(ts: number, now: number): string {
|
||||
const d = new Date(ts);
|
||||
const n = new Date(now);
|
||||
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const dayDiff = Math.round((startOf(d) - startOf(n)) / 86_400_000);
|
||||
if (dayDiff <= 0) return 'Today';
|
||||
if (dayDiff === 1) return 'Tomorrow';
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function targetLabel(task: ScheduledTask): string {
|
||||
if (task.target_type === 'stack') return (task.target_id ?? task.name).replace(/\.(ya?ml)$/, '');
|
||||
if (task.target_type === 'fleet') return 'fleet';
|
||||
return task.target_type;
|
||||
}
|
||||
|
||||
interface UpcomingRun {
|
||||
task: ScheduledTask;
|
||||
runAt: number;
|
||||
}
|
||||
|
||||
export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await apiFetch('/scheduled-tasks', { localOnly: true, signal: controller.signal });
|
||||
if (res.ok) {
|
||||
setTasks(await res.json() as ScheduledTask[]);
|
||||
} else {
|
||||
console.error('Scheduled tasks poll failed:', res.status);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.error('Failed to fetch scheduled tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// fetchTasks sets state only after an await, so it does not cause the
|
||||
// synchronous cascading render this rule guards against; the rule flags the
|
||||
// call conservatively because it can't follow the async boundary.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchTasks();
|
||||
const id = setInterval(() => void fetchTasks(), 60_000);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const enabledCount = tasks.filter(t => t.enabled === 1).length;
|
||||
const upcoming: UpcomingRun[] = tasks
|
||||
.filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0)
|
||||
.flatMap(task => (task.next_runs ?? []).map(runAt => ({ task, runAt })))
|
||||
.filter(p => p.runAt >= now)
|
||||
.sort((a, b) => a.runAt - b.runAt)
|
||||
.slice(0, 60);
|
||||
|
||||
const next = upcoming[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker={`schedules · ${enabledCount} active`}
|
||||
state={next ? hhmm(next.runAt) : '--:--'}
|
||||
stateTone="brand"
|
||||
live={false}
|
||||
meta={next ? `${relative(next.runAt, now)} · ${ACTION_LABEL[next.task.action]} ${targetLabel(next.task)}` : 'nothing scheduled'}
|
||||
right={headerActions}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px]">
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : upcoming.length === 0 ? (
|
||||
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">
|
||||
Nothing scheduled. Create a schedule on desktop to automate recurring operations.
|
||||
</p>
|
||||
) : (
|
||||
upcoming.map((run, i) => {
|
||||
const prevDay = i > 0 ? dayLabel(upcoming[i - 1].runAt, now) : null;
|
||||
const day = dayLabel(run.runAt, now);
|
||||
const tone = ACTION_TONE[run.task.action];
|
||||
return (
|
||||
<div key={`${run.task.id}-${run.runAt}`}>
|
||||
{day !== prevDay ? <SectionHead>{day}</SectionHead> : null}
|
||||
<div className="flex items-center gap-2.5 py-2">
|
||||
<span className="w-[46px] shrink-0 font-mono tabular-nums text-[13px] text-stat-value">{hhmm(run.runAt)}</span>
|
||||
<StateDot tone={tone} size={7} glow />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-subtitle">
|
||||
<span className="text-stat-value">{ACTION_LABEL[run.task.action]}</span>{` ${targetLabel(run.task)}`}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import {
|
||||
SETTINGS_GROUPS,
|
||||
SETTINGS_ITEMS,
|
||||
getSettingsItem,
|
||||
getSettingsGroup,
|
||||
isItemVisible,
|
||||
isItemLocked,
|
||||
} from '@/components/settings';
|
||||
import type { SectionId } from '@/components/settings';
|
||||
import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent';
|
||||
import { BackChip, Kicker, Masthead } from './mobile-ui';
|
||||
|
||||
interface MobileSettingsProps {
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export function MobileSettings({ headerActions }: MobileSettingsProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const nodeName = activeNode?.name ?? 'local';
|
||||
const visibility = { isRemote, isAdmin, isPaid };
|
||||
|
||||
const visibleItems = SETTINGS_ITEMS.filter(
|
||||
item => isItemVisible(item, visibility) && !isItemLocked(item, visibility),
|
||||
);
|
||||
const groups = SETTINGS_GROUPS
|
||||
.map(group => ({ ...group, items: visibleItems.filter(item => item.group === group.id) }))
|
||||
.filter(group => group.items.length > 0);
|
||||
|
||||
const [selected, setSelected] = useState<SectionId | null>(null);
|
||||
// If the active node changes and hides the open section, fall back to the list.
|
||||
const activeSection = selected && visibleItems.some(i => i.id === selected) ? selected : null;
|
||||
const item = activeSection ? getSettingsItem(activeSection) : null;
|
||||
|
||||
if (activeSection && item) {
|
||||
const group = getSettingsGroup(item.group);
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="px-2 pt-1">
|
||||
<BackChip label="Settings" onClick={() => setSelected(null)} />
|
||||
</div>
|
||||
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
|
||||
<span aria-hidden className="absolute left-0 top-1 bottom-[15px] w-[3px] bg-brand" />
|
||||
<div className="mb-1"><Kicker>{`settings · ${group?.label ?? ''} · ${item.label}`}</Kicker></div>
|
||||
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} isPaid={isPaid} onDirtyChange={NOOP} showDescription />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Masthead
|
||||
kicker={`settings · ${nodeName}`}
|
||||
state="Settings"
|
||||
stateTone="brand"
|
||||
live={false}
|
||||
right={headerActions}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
|
||||
{groups.map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel"
|
||||
>
|
||||
<div className="border-b border-hairline px-[13px] py-2.5">
|
||||
<Kicker>{group.kicker ? `${group.label} · ${group.kicker}` : group.label}</Kicker>
|
||||
</div>
|
||||
{group.items.map((it, idx) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(it.id)}
|
||||
className={`flex min-h-11 w-full items-center gap-3 px-[13px] py-3 text-left ${idx > 0 ? 'border-t border-hairline' : ''}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] text-stat-value">{it.label}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-stat-icon">{it.description}</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-stat-icon" strokeWidth={1.6} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Shared building blocks for the bespoke mobile (<md) screens. These translate
|
||||
// the approved prototype's inline-styled primitives (docs/design/mobile-reference)
|
||||
// onto our design tokens. No new colors or fonts. Rendered only on the mobile
|
||||
// shell surfaces.
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkline } from '@/components/ui/sparkline';
|
||||
|
||||
export type Tone = 'success' | 'warning' | 'destructive' | 'brand';
|
||||
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
brand: 'text-brand',
|
||||
};
|
||||
const TONE_BG: Record<Tone, string> = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
brand: 'bg-brand',
|
||||
};
|
||||
const TONE_MUTED: Record<Tone, string> = {
|
||||
success: 'bg-success/[0.14]',
|
||||
warning: 'bg-warning/[0.14]',
|
||||
destructive: 'bg-destructive/[0.14]',
|
||||
brand: 'bg-brand/[0.09]',
|
||||
};
|
||||
const TONE_VAR: Record<Tone, string> = {
|
||||
success: 'var(--success)',
|
||||
warning: 'var(--warning)',
|
||||
destructive: 'var(--destructive)',
|
||||
brand: 'var(--brand)',
|
||||
};
|
||||
|
||||
// Bar fill color by threshold: >=90 destructive, >=80 warning, else brand.
|
||||
function barColor(pct: number): string {
|
||||
if (pct >= 90) return TONE_VAR.destructive;
|
||||
if (pct >= 80) return TONE_VAR.warning;
|
||||
return TONE_VAR.brand;
|
||||
}
|
||||
|
||||
// Tracked-mono uppercase label.
|
||||
export function Kicker({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={cn('font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-icon', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Section header on a thin top rule.
|
||||
export function SectionHead({ children, right }: { children: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-hairline pt-[9px] mb-[9px]">
|
||||
<Kicker>{children}</Kicker>
|
||||
{right ? <Kicker className="text-stat-subtitle">{right}</Kicker> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StateDot({ tone = 'success', size = 7, glow = false, pulse = false }: { tone?: Tone; size?: number; glow?: boolean; pulse?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-block shrink-0 rounded-full', TONE_BG[tone], pulse && 'animate-pulse')}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
boxShadow: glow || pulse ? `0 0 6px 0 ${TONE_VAR[tone]}` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal gauge bar (5px, recessed well track, threshold fill).
|
||||
export function Bar({ pct }: { pct: number }) {
|
||||
return (
|
||||
<div className="mt-[7px] h-[5px] overflow-hidden rounded-[3px] bg-well">
|
||||
<div className="h-full rounded-[3px]" style={{ width: `${Math.max(0, Math.min(100, pct))}%`, background: barColor(pct) }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sparkline wrapper matching the reference (gradient fill + amber peak dot).
|
||||
export function MSparkline({ values, height = 30, color = 'var(--brand)', peak = true }: { values: number[]; height?: number; color?: string; peak?: boolean }) {
|
||||
return (
|
||||
<div style={{ height }}>
|
||||
<Sparkline points={values} stroke={color} fill={color} peakColor="var(--warning)" showPeak={peak} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// State pill (online, degraded, etc.).
|
||||
export function StatePill({ tone = 'success', live = false, children }: { tone?: Tone; live?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-1.5 rounded-[7px] border px-[9px] py-[3px] font-mono text-[11px] tracking-[0.04em]', TONE_TEXT[tone], TONE_MUTED[tone])}
|
||||
style={{ borderColor: `color-mix(in oklch, ${TONE_VAR[tone]} 30%, transparent)` }}
|
||||
>
|
||||
<StateDot tone={tone} size={6} pulse={live} glow={!live} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Mono button: primary (cyan) / outline / ghost. >=44px tall.
|
||||
export function MBtn({
|
||||
kind = 'outline',
|
||||
children,
|
||||
full = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className,
|
||||
}: { kind?: 'primary' | 'outline' | 'ghost'; children: ReactNode; full?: boolean; onClick?: () => void; disabled?: boolean; className?: string }) {
|
||||
const variant =
|
||||
kind === 'primary' ? 'bg-brand text-brand-foreground shadow-btn-glow'
|
||||
: kind === 'outline' ? 'bg-card text-stat-title border border-card-border border-t-card-border-top shadow-btn-glow'
|
||||
: 'bg-transparent text-stat-subtitle';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border border-transparent px-[14px] font-mono text-[11px] font-medium uppercase tracking-[0.12em] whitespace-nowrap transition-colors disabled:opacity-50',
|
||||
full && 'w-full',
|
||||
variant,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Back chevron chip for drill-in detail screens.
|
||||
export function BackChip({ label, onClick }: { label: string; onClick?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex min-h-11 items-center gap-1 rounded-[7px] pl-0.5 pr-2 font-mono text-xs text-brand"
|
||||
>
|
||||
<svg width="8" height="13" viewBox="0 0 8 13" aria-hidden="true">
|
||||
<path d="M6.5 1L1 6.5 6.5 12" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Status masthead for the bespoke mobile content leads (Home and Fleet today;
|
||||
// Schedules as it is re-skinned): 3px cyan left rail, kicker, serif-italic
|
||||
// state word, meta, optional right slot.
|
||||
export function Masthead({
|
||||
kicker,
|
||||
state,
|
||||
stateTone = 'success',
|
||||
meta,
|
||||
right,
|
||||
live = true,
|
||||
stateClassName,
|
||||
}: {
|
||||
kicker: ReactNode;
|
||||
state: ReactNode;
|
||||
stateTone?: Tone;
|
||||
meta?: ReactNode;
|
||||
right?: ReactNode;
|
||||
live?: boolean;
|
||||
stateClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative border-b border-hairline px-4 pb-[15px] pt-2">
|
||||
<span aria-hidden className="absolute left-0 top-2 bottom-[15px] w-[3px] bg-brand" />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1"><Kicker>{kicker}</Kicker></div>
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<StateDot tone={stateTone} size={11} pulse={live} />
|
||||
<span className={cn('font-display italic text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
|
||||
</div>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 text-right">{right}</div> : null}
|
||||
</div>
|
||||
{meta ? <div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">{meta}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user