mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat(scheduler): add helper text and risk badges to scheduled action picker (#1449)
* feat(scheduler): add helper text and risk badges to scheduled action picker Add a concise helper text and risk level badge to every scheduled action in the create/edit modal. The six risk levels (Safe, Read-only, Interruptive, Runtime change, Removes containers, Destructive) map to the four existing design-system tones and render as a small dot+label chip next to the helper text, following the same pattern as SeverityBadge. Fix an ambiguous mobile label: update + target_type: fleet now resolves through resolveTaskAction and renders 'update node stacks' instead of the misleading 'update fleet'. Add exact helper-text and risk-level assertions for all 10 actions, plus component tests for default modal state, action-switch scenarios, and mobile update+fleet rendering. * docs: update stale scheduled-operations alt text for changed helper text
This commit is contained in:
@@ -15,12 +15,16 @@ import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
import { getCronDescription, getCronFieldError, formatTimestamp } from '@/lib/scheduling';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SCHEDULED_ACTIONS,
|
||||
SCHEDULED_ACTION_CATEGORIES,
|
||||
getActionById,
|
||||
resolveTaskAction,
|
||||
DEFAULT_SCHEDULED_ACTION_ID,
|
||||
RISK_BADGE_CLASSES,
|
||||
RISK_DOT_CLASSES,
|
||||
RISK_LABEL,
|
||||
} from '@/lib/scheduledActions';
|
||||
|
||||
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
|
||||
@@ -685,6 +689,18 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
|
||||
placeholder="Select action..."
|
||||
/>
|
||||
{currentAction && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium shrink-0 mt-0.5',
|
||||
RISK_BADGE_CLASSES[currentAction.riskLevel],
|
||||
)}>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', RISK_DOT_CLASSES[currentAction.riskLevel])} />
|
||||
{RISK_LABEL[currentAction.riskLevel]}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">{currentAction.helperText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAction?.requiresStack && (
|
||||
@@ -750,9 +766,6 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
{currentAction.helperText && (
|
||||
<p className="text-xs text-muted-foreground">{currentAction.helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -454,4 +454,44 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk badge and helper text', () => {
|
||||
it('shows Interruptive badge and helper for the default action Restart Stack', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
|
||||
expect(screen.getByText('Interruptive')).toBeInTheDocument();
|
||||
expect(screen.getByText(
|
||||
'Restarts containers in place. Running services are stopped and started again on the same configuration.',
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Safe badge and helper for Create Fleet Snapshot', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
|
||||
// Switch action to Create Fleet Snapshot (a non-node, non-stack action).
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' }));
|
||||
|
||||
expect(screen.getByText('Safe')).toBeInTheDocument();
|
||||
expect(screen.getByText(
|
||||
'Creates a versioned snapshot of compose and env files across the fleet.',
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Destructive badge and helper for Prune Node Resources', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
|
||||
// Switch action to Prune Node Resources.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
|
||||
|
||||
expect(screen.getByText('Destructive')).toBeInTheDocument();
|
||||
expect(screen.getByText(
|
||||
'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,19 @@ 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 { getActionById } from '@/lib/scheduledActions';
|
||||
import { resolveTaskAction } from '@/lib/scheduledActions';
|
||||
import { Masthead, SectionHead, StateDot, type Tone } from './mobile-ui';
|
||||
|
||||
interface MobileSchedulesProps {
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
function actionTone(action: ScheduledTask['action']): Tone {
|
||||
return getActionById(action)?.tone ?? 'brand';
|
||||
function actionTone(task: ScheduledTask): Tone {
|
||||
return resolveTaskAction(task)?.tone ?? 'brand';
|
||||
}
|
||||
|
||||
function actionShortLabel(action: ScheduledTask['action']): string {
|
||||
return getActionById(action)?.shortLabel ?? action;
|
||||
function actionShortLabel(task: ScheduledTask): string {
|
||||
return resolveTaskAction(task)?.shortLabel ?? task.action;
|
||||
}
|
||||
|
||||
function hhmm(ts: number): string {
|
||||
@@ -44,7 +44,10 @@ function dayLabel(ts: number, now: number): string {
|
||||
|
||||
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';
|
||||
if (task.target_type === 'fleet') {
|
||||
if (task.action === 'update') return 'stacks';
|
||||
return 'fleet';
|
||||
}
|
||||
return task.target_type;
|
||||
}
|
||||
|
||||
@@ -113,7 +116,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
|
||||
state={next ? hhmm(next.runAt) : '--:--'}
|
||||
stateTone="brand"
|
||||
live={false}
|
||||
meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task.action)} ${targetLabel(next.task)}` : 'nothing scheduled'}
|
||||
meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task)} ${targetLabel(next.task)}` : 'nothing scheduled'}
|
||||
right={headerActions}
|
||||
/>
|
||||
|
||||
@@ -130,7 +133,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
|
||||
upcoming.map((run, i) => {
|
||||
const prevDay = i > 0 ? dayLabel(upcoming[i - 1].runAt, now) : null;
|
||||
const day = dayLabel(run.runAt, now);
|
||||
const tone = actionTone(run.task.action);
|
||||
const tone = actionTone(run.task);
|
||||
return (
|
||||
<div key={`${run.task.id}-${run.runAt}`}>
|
||||
{day !== prevDay ? <SectionHead>{day}</SectionHead> : null}
|
||||
@@ -138,7 +141,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
|
||||
<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">{actionShortLabel(run.task.action)}</span>{` ${targetLabel(run.task)}`}
|
||||
<span className="text-stat-value">{actionShortLabel(run.task)}</span>{` ${targetLabel(run.task)}`}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
|
||||
</div>
|
||||
|
||||
@@ -64,4 +64,35 @@ describe('MobileSchedules', () => {
|
||||
// renders with the destructive class only if the tone is wired through.
|
||||
expect(container.querySelector('.bg-destructive')).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('update + fleet target', () => {
|
||||
it('renders node-scoped copy, not fleet-ambiguous text', async () => {
|
||||
mockedFetch.mockResolvedValue(jsonResponse([
|
||||
makeTask({ id: 1, action: 'update', target_type: 'fleet', target_id: null, node_id: 2 }),
|
||||
]));
|
||||
|
||||
render(<MobileSchedules headerActions={null} />);
|
||||
|
||||
const row = await screen.findByText('update node');
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
// The row must not contain the forbidden ambiguous copy.
|
||||
expect(screen.queryByText('update fleet')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('update all')).not.toBeInTheDocument();
|
||||
|
||||
// The target label for update+fleet renders 'stacks', not 'fleet'.
|
||||
expect(screen.getByText('stacks')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fleet target label for a snapshot task', async () => {
|
||||
mockedFetch.mockResolvedValue(jsonResponse([
|
||||
makeTask({ id: 1, action: 'snapshot', target_type: 'fleet', target_id: null, node_id: null }),
|
||||
]));
|
||||
|
||||
render(<MobileSchedules headerActions={null} />);
|
||||
|
||||
// Snapshot IS fleet-wide; 'fleet' in the target label is correct.
|
||||
expect(await screen.findByText('fleet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user