mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +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:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Plus, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
@@ -40,9 +40,19 @@ import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-e
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
import { MobileMoreMenu } from './MobileMoreMenu';
|
||||
import { MobileDashboard } from './mobile/MobileDashboard';
|
||||
import { MobileFleet } from './mobile/MobileFleet';
|
||||
import { MobileSchedules } from './mobile/MobileSchedules';
|
||||
import { MobileSettings } from './mobile/MobileSettings';
|
||||
import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surface';
|
||||
import type { SectionId } from './settings/types';
|
||||
|
||||
// Content views that render a bespoke, masthead-led mobile screen instead of the
|
||||
// reflowed desktop workspace. For these the global TopBar is dropped on mobile
|
||||
// (each screen's masthead leads). The set grows as screens are re-skinned.
|
||||
const BESPOKE_MOBILE_VIEWS = new Set<string>(['dashboard', 'fleet', 'scheduled-ops', 'settings']);
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
@@ -261,6 +271,31 @@ export default function EditorLayout() {
|
||||
void stackActions.loadFile(file);
|
||||
};
|
||||
|
||||
// Open a specific stack on a node (from Fleet): load it directly if that node
|
||||
// is already active, else stash it and switch nodes (the node-switch effect
|
||||
// loads the pending stack once the registry settles). Mobile shows the
|
||||
// optimistic detail surface immediately.
|
||||
const handleFleetNavigateToNode = (nodeId: number, stackName: string) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) return;
|
||||
if (isMobile) setPendingDetailStack(stackName);
|
||||
if (activeNode?.id === nodeId) {
|
||||
void stackActions.loadFile(stackName);
|
||||
} else {
|
||||
pendingStackLoadRef.current = stackName;
|
||||
setActiveNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
// "Inspect" a node from the mobile Fleet screen: switch to it and land on its
|
||||
// stack list.
|
||||
const handleInspectNode = (nodeId: number) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) return;
|
||||
if (activeNode?.id !== nodeId) setActiveNode(node);
|
||||
goToMobileList();
|
||||
};
|
||||
|
||||
// Hamburger / command-palette navigation is mobile-aware so it collapses the
|
||||
// current surface and honors the unsaved-changes guard; desktop is untouched.
|
||||
const navHandler = isMobile ? navigateMobileAware : handleNavigate;
|
||||
@@ -286,8 +321,9 @@ export default function EditorLayout() {
|
||||
}
|
||||
}, [stackActions, setActiveView, isMobile, navigateMobileAware]);
|
||||
|
||||
const renderEditor = () => (
|
||||
const renderEditor = (headerActions?: ReactNode) => (
|
||||
<EditorView
|
||||
headerActions={headerActions}
|
||||
stackName={stackName}
|
||||
isDarkMode={isDarkMode}
|
||||
containers={containers}
|
||||
@@ -525,6 +561,19 @@ export default function EditorLayout() {
|
||||
/>
|
||||
);
|
||||
|
||||
const notificationsEl = (
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
nodes={nodes}
|
||||
onMarkAllRead={markAllRead}
|
||||
onClearAll={clearAllNotifications}
|
||||
onDelete={deleteNotification}
|
||||
onNavigate={stackActions.navigateToNotification}
|
||||
/>
|
||||
);
|
||||
const themeSwitchEl = <ThemeQuickSwitch />;
|
||||
const userMenuEl = <UserProfileDropdown onOpenSettings={() => openSettings('account')} />;
|
||||
|
||||
const topBarEl = (
|
||||
<TopBar
|
||||
activeView={activeView}
|
||||
@@ -533,25 +582,27 @@ export default function EditorLayout() {
|
||||
mobileNavOpen={mobileNavOpen}
|
||||
onMobileNavOpenChange={setMobileNavOpen}
|
||||
search={<GlobalCommandPaletteTrigger />}
|
||||
themeSwitch={<ThemeQuickSwitch />}
|
||||
notifications={
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
nodes={nodes}
|
||||
onMarkAllRead={markAllRead}
|
||||
onClearAll={clearAllNotifications}
|
||||
onDelete={deleteNotification}
|
||||
onNavigate={stackActions.navigateToNotification}
|
||||
/>
|
||||
}
|
||||
userMenu={
|
||||
<UserProfileDropdown
|
||||
onOpenSettings={() => openSettings('account')}
|
||||
/>
|
||||
}
|
||||
themeSwitch={themeSwitchEl}
|
||||
notifications={notificationsEl}
|
||||
userMenu={userMenuEl}
|
||||
/>
|
||||
);
|
||||
|
||||
// On the bespoke mobile screens the TopBar is dropped, so notifications and
|
||||
// the secondary-destinations menu are rehomed into each screen's masthead
|
||||
// right slot.
|
||||
const mobileMastheadActions = (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{notificationsEl}
|
||||
<MobileMoreMenu
|
||||
navItems={navItems}
|
||||
activeView={activeView}
|
||||
onNavigate={navigateMobileAware}
|
||||
footer={<>{themeSwitchEl}{userMenuEl}</>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const workspaceEl = (
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 max-md:p-4 animate-fade-up">
|
||||
<ViewRouter
|
||||
@@ -565,17 +616,7 @@ export default function EditorLayout() {
|
||||
void stackActions.loadFile(sName);
|
||||
}}
|
||||
onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')}
|
||||
onFleetNavigateToNode={(nodeId, sName) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
if (activeNode?.id === nodeId) {
|
||||
void stackActions.loadFile(sName);
|
||||
} else {
|
||||
pendingStackLoadRef.current = sName;
|
||||
setActiveNode(node);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFleetNavigateToNode={handleFleetNavigateToNode}
|
||||
filterNodeId={filterNodeId}
|
||||
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
||||
schedulePrefill={schedulePrefill}
|
||||
@@ -605,19 +646,51 @@ export default function EditorLayout() {
|
||||
/>
|
||||
);
|
||||
|
||||
// Bespoke, masthead-led mobile screens. When showing one, the TopBar is
|
||||
// dropped and the screen renders its own masthead (with notifications +
|
||||
// more-menu rehomed into the right slot).
|
||||
const bespokeContent = mobileSurface === 'content' && BESPOKE_MOBILE_VIEWS.has(activeView);
|
||||
const renderMobileBespoke = () => {
|
||||
switch (activeView) {
|
||||
case 'dashboard':
|
||||
return (
|
||||
<MobileDashboard
|
||||
notifications={notifications}
|
||||
headerActions={mobileMastheadActions}
|
||||
onNavigateToStack={handleSelectStack}
|
||||
onViewAllStacks={goToMobileList}
|
||||
/>
|
||||
);
|
||||
case 'fleet':
|
||||
return (
|
||||
<MobileFleet
|
||||
headerActions={mobileMastheadActions}
|
||||
onInspectNode={handleInspectNode}
|
||||
onInspectStack={handleFleetNavigateToNode}
|
||||
/>
|
||||
);
|
||||
case 'scheduled-ops':
|
||||
return <MobileSchedules headerActions={mobileMastheadActions} />;
|
||||
case 'settings':
|
||||
return <MobileSettings headerActions={mobileMastheadActions} />;
|
||||
default:
|
||||
return workspaceEl;
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden app-canvas text-foreground">
|
||||
{commandPaletteEl}
|
||||
{mobileSurface !== 'detail' && topBarEl}
|
||||
{mobileSurface !== 'detail' && !bespokeContent && topBarEl}
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{mobileSurface === 'list' && sidebarEl}
|
||||
{mobileSurface === 'content' && workspaceEl}
|
||||
{mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)}
|
||||
{mobileSurface === 'detail' && (
|
||||
detailReady ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor()}</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor(mobileMastheadActions)}</div>
|
||||
) : (
|
||||
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} />
|
||||
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} headerActions={mobileMastheadActions} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -626,6 +699,7 @@ export default function EditorLayout() {
|
||||
activeView={activeView}
|
||||
mobileView={mobileView}
|
||||
detailOpen={detailOpen}
|
||||
onHome={() => navigateMobileAware('dashboard')}
|
||||
onStacks={goToMobileList}
|
||||
onNavigate={navigateMobileAware}
|
||||
onSettings={openSettingsMobileAware}
|
||||
@@ -657,7 +731,7 @@ export default function EditorLayout() {
|
||||
// Optimistic stack-detail placeholder shown on mobile the instant a row is
|
||||
// tapped, until loadFile resolves and the real EditorView mounts. Keeps the tap
|
||||
// feeling immediate on slow networks.
|
||||
function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => void }) {
|
||||
function MobileDetailLoading({ name, onBack, headerActions }: { name: string; onBack: () => void; headerActions?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center gap-1 border-b border-hairline px-3 py-2">
|
||||
@@ -670,9 +744,10 @@ function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => voi
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
||||
Stacks
|
||||
</button>
|
||||
<span className="truncate font-display text-2xl italic text-stat-value">
|
||||
<span className="min-w-0 flex-1 truncate font-display text-2xl italic text-stat-value">
|
||||
{name.replace(/\.(ya?ml)$/, '')}
|
||||
</span>
|
||||
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
|
||||
Reference in New Issue
Block a user