mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
928a3a8343
* 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.
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|