diff --git a/apps/application-admin-console/src/api/disruptions-client.ts b/apps/application-admin-console/src/api/disruptions-client.ts index b1b2bc6ea..e049592f9 100644 --- a/apps/application-admin-console/src/api/disruptions-client.ts +++ b/apps/application-admin-console/src/api/disruptions-client.ts @@ -14,6 +14,9 @@ import type { ApiClient } from "./client"; export type DisruptionScope = "all" | "team" | "random-n"; +/** [ADR-037] When the injection runs: immediately, or scheduled `afterMinutes` from now. */ +export type DisruptionTiming = "immediate" | "scheduled"; + /** One declared disruption as surfaced by the catalog (a problem's metadata.json declaration). */ export interface DisruptionCatalogItem { readonly id: string; @@ -26,6 +29,8 @@ export interface DisruptionCatalogItem { readonly parameters?: Readonly>; /** Whether competitors can see this disruption exists (operator view shows all). */ readonly publicHint?: boolean; + /** [ADR-037] Declared default delay (minutes) used to pre-fill the schedule input. */ + readonly defaultAfterMinutes?: number; } export interface DisruptionCatalogEntry { @@ -46,6 +51,10 @@ export interface FireDisruptionRequest { readonly parameters?: Readonly>; /** Idempotency key (>= 8 chars); re-firing with the same id is a no-op on the platform. */ readonly requestId: string; + /** [ADR-037] `immediate` (default) injects now; `scheduled` defers by `afterMinutes`. */ + readonly timing?: DisruptionTiming; + /** [ADR-037] Required when `timing === "scheduled"`; 1–1440 minutes. */ + readonly afterMinutes?: number; } export interface FireDisruptionResult { @@ -64,6 +73,8 @@ export interface DisruptionAuditRow { readonly targetTeamIds: readonly string[]; readonly parameters: Readonly>; readonly requestId: string; + /** [ADR-037] For a scheduled fire, the time the injection is/was due (ISO8601). */ + readonly scheduledFor?: string; } export interface DisruptionAuditResponse { diff --git a/apps/application-admin-console/src/i18n/locales/en.json b/apps/application-admin-console/src/i18n/locales/en.json index 41f702ea7..b9c194b28 100644 --- a/apps/application-admin-console/src/i18n/locales/en.json +++ b/apps/application-admin-console/src/i18n/locales/en.json @@ -705,6 +705,7 @@ "col_description": "Description", "col_scope": "Scope", "col_affected": "Teams hit", + "col_scheduled_for": "Scheduled for", "col_fired_at": "Fired at", "fire_button": "Fire", "loading": "Loading disruptions…", @@ -723,6 +724,14 @@ "teams_placeholder": "Select teams", "random_label": "Pick the pool of teams (a random subset is hit)", "random_description": "The platform hits a random subset sized to your selection.", - "fired_flash": "Fired \"{name}\" at {count} team(s)." + "timing_label": "Timing", + "timing_description": "Inject immediately, or schedule it to run after a number of minutes.", + "timing_immediate": "Run now", + "timing_scheduled": "Schedule (in N min)", + "after_minutes_label": "Run after (minutes)", + "after_minutes_description": "1–1440 minutes. The injection runs once this many minutes have elapsed.", + "after_minutes_error": "Enter a whole number between 1 and 1440.", + "fired_flash": "Fired \"{name}\" at {count} team(s).", + "scheduled_flash": "Scheduled \"{name}\" to fire in {minutes} minute(s)." } } diff --git a/apps/application-admin-console/src/i18n/locales/ja.json b/apps/application-admin-console/src/i18n/locales/ja.json index 067e6704b..f4298e220 100644 --- a/apps/application-admin-console/src/i18n/locales/ja.json +++ b/apps/application-admin-console/src/i18n/locales/ja.json @@ -705,6 +705,7 @@ "col_description": "説明", "col_scope": "対象範囲", "col_affected": "対象チーム数", + "col_scheduled_for": "注入予定時刻", "col_fired_at": "発火時刻", "fire_button": "発火", "loading": "障害を読み込み中…", @@ -723,6 +724,14 @@ "teams_placeholder": "チームを選択", "random_label": "母集団のチームを選ぶ (ここからランダムな一部に当たります)", "random_description": "選択した数に応じて、 platform がランダムな一部に当てます。", - "fired_flash": "「{name}」 を {count} チームに発火しました。" + "timing_label": "実行タイミング", + "timing_description": "即座に注入するか、 指定した分数後に予約するかを選びます。", + "timing_immediate": "即座に実行", + "timing_scheduled": "予約 (N 分後)", + "after_minutes_label": "何分後に実行するか", + "after_minutes_description": "1〜1440 分。 この分数が経過した時点で注入が走ります。", + "after_minutes_error": "1〜1440 の整数を入力してください。", + "fired_flash": "「{name}」 を {count} チームに発火しました。", + "scheduled_flash": "「{name}」 を {minutes} 分後に発火するよう予約しました。" } } diff --git a/apps/application-admin-console/src/pages/event-detail/DisruptionsPanel.tsx b/apps/application-admin-console/src/pages/event-detail/DisruptionsPanel.tsx index 6c22ecd7e..45cae66d4 100644 --- a/apps/application-admin-console/src/pages/event-detail/DisruptionsPanel.tsx +++ b/apps/application-admin-console/src/pages/event-detail/DisruptionsPanel.tsx @@ -4,8 +4,10 @@ import Button from "@cloudscape-design/components/button"; import Container from "@cloudscape-design/components/container"; import FormField from "@cloudscape-design/components/form-field"; import Header from "@cloudscape-design/components/header"; +import Input from "@cloudscape-design/components/input"; import Modal from "@cloudscape-design/components/modal"; import Multiselect from "@cloudscape-design/components/multiselect"; +import SegmentedControl from "@cloudscape-design/components/segmented-control"; import Select from "@cloudscape-design/components/select"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Table from "@cloudscape-design/components/table"; @@ -16,6 +18,8 @@ import { type DisruptionAuditRow, type DisruptionCatalogEntry, type DisruptionScope, + type DisruptionTiming, + type FireDisruptionRequest, fetchDisruptionAudit, fetchDisruptionCatalog, fireDisruption, @@ -25,14 +29,181 @@ import type { TeamSummary } from "../../api/events-client"; type Translate = (key: string, params?: Readonly>) => string; +type TeamOption = { readonly value: string; readonly label: string }; + const SCOPE_OPTIONS: readonly DisruptionScope[] = ["all", "team", "random-n"]; const AUDIT_LIMIT = 20; +const DEFAULT_AFTER_MINUTES = 30; +const MAX_AFTER_MINUTES = 1440; interface FireTarget { readonly problemId: string; readonly item: DisruptionCatalogEntry["disruption"]; } +/** Build the fire request from the modal state (pure — keeps the modal flat). */ +function buildFireRequest( + target: FireTarget, + scope: DisruptionScope, + selectedTeamIds: readonly string[], + timing: DisruptionTiming, + afterMinutes: number, +): FireDisruptionRequest { + return { + problemId: target.problemId, + disruptionId: target.item.id, + scope, + ...(scope === "team" ? { targetTeamIds: selectedTeamIds } : {}), + ...(scope === "random-n" ? { randomCount: Math.max(selectedTeamIds.length, 1) } : {}), + ...(target.item.parameters ? { parameters: target.item.parameters } : {}), + ...(timing === "scheduled" ? { timing: "scheduled" as const, afterMinutes } : {}), + requestId: newFireRequestId(), + }; +} + +/** + * Fire modal — owns its own form state (scope / timing / minutes), fires once, and reports the + * success flash back to the panel. Extracted so the panel stays a thin list + the form is a + * cohesive unit ([ADR-037] adds the immediate/scheduled timing toggle here). + */ +function FireModal({ + apiClient, + eventId, + target, + teamOptions, + t, + onClose, + onFired, +}: { + readonly apiClient: ApiClient; + readonly eventId: string; + readonly target: FireTarget; + readonly teamOptions: readonly TeamOption[]; + readonly t: Translate; + readonly onClose: () => void; + readonly onFired: (flash: string) => void; +}) { + const [scope, setScope] = useState("all"); + const [selectedTeamIds, setSelectedTeamIds] = useState([]); + const [timing, setTiming] = useState("immediate"); + const [afterMinutes, setAfterMinutes] = useState( + target.item.defaultAfterMinutes ?? DEFAULT_AFTER_MINUTES, + ); + const [firing, setFiring] = useState(false); + const [fireError, setFireError] = useState(null); + + const scheduleInvalid = + timing === "scheduled" && + (!Number.isInteger(afterMinutes) || afterMinutes < 1 || afterMinutes > MAX_AFTER_MINUTES); + const fireDisabled = + firing || (scope === "team" && selectedTeamIds.length === 0) || scheduleInvalid; + + const confirmFire = async () => { + setFiring(true); + setFireError(null); + try { + const result = await fireDisruption( + apiClient, + eventId, + buildFireRequest(target, scope, selectedTeamIds, timing, afterMinutes), + ); + onFired( + timing === "scheduled" + ? t("disruptions.scheduled_flash", { name: target.item.name, minutes: afterMinutes }) + : t("disruptions.fired_flash", { + name: target.item.name, + count: result.affectedTeamIds.length, + }), + ); + } catch (err) { + setFireError(toErrorMessage(err)); + } finally { + setFiring(false); + } + }; + + const teamPicker = (label: string, description?: string) => ( + + selectedTeamIds.includes(o.value))} + options={teamOptions} + onChange={(e) => setSelectedTeamIds(e.detail.selectedOptions.map((o) => o.value as string))} + placeholder={t("disruptions.teams_placeholder")} + /> + + ); + + return ( + + + + + + + } + > + + {fireError ? {fireError} : null} + {target.item.description} + + setAfterMinutes(Number(e.detail.value))} + /> + + ) : null} + + + ); +} + /** * [#1417 / #1666] Operator red-team console. Lists the event's declared disruptions (catalog) and * lets the operator fire one at a scope (all / team / random-n); shows the fire audit log. Generic @@ -55,14 +226,10 @@ export function DisruptionsPanel({ const [audit, setAudit] = useState([]); const [loadError, setLoadError] = useState(null); const [fireTarget, setFireTarget] = useState(null); - const [scope, setScope] = useState("all"); - const [selectedTeamIds, setSelectedTeamIds] = useState([]); - const [firing, setFiring] = useState(false); - const [fireError, setFireError] = useState(null); const [lastFired, setLastFired] = useState(null); const reloadAudit = useCallback(async () => { - // Only called from confirmFire, which already guarded apiClient — defensive, unreachable. + // Only called after a successful fire (apiClient was present) — defensive, unreachable. /* v8 ignore next */ if (!apiClient) return; const res = await fetchDisruptionAudit(apiClient, eventId, { limit: AUDIT_LIMIT }); @@ -83,52 +250,17 @@ export function DisruptionsPanel({ .catch((err) => setLoadError(toErrorMessage(err))); }, [apiClient, eventId]); - const teamOptions = useMemo( + const teamOptions = useMemo( () => teams.map((tm) => ({ value: tm.teamId, label: tm.displayName || tm.internalSlug })), [teams], ); - const openFire = (target: FireTarget) => { - setFireTarget(target); - setScope("all"); - setSelectedTeamIds([]); - setFireError(null); + const onFired = (flash: string) => { + setLastFired(flash); + setFireTarget(null); + void reloadAudit(); }; - const confirmFire = useCallback(async () => { - // The confirm button only renders inside the modal (fireTarget set), and the catalog/Fire - // buttons only render when apiClient is present — so this guard is defensive, unreachable. - /* v8 ignore next */ - if (!apiClient || !fireTarget) return; - setFiring(true); - setFireError(null); - try { - const result = await fireDisruption(apiClient, eventId, { - problemId: fireTarget.problemId, - disruptionId: fireTarget.item.id, - scope, - ...(scope === "team" ? { targetTeamIds: selectedTeamIds } : {}), - ...(scope === "random-n" ? { randomCount: Math.max(selectedTeamIds.length, 1) } : {}), - ...(fireTarget.item.parameters ? { parameters: fireTarget.item.parameters } : {}), - requestId: newFireRequestId(), - }); - setLastFired( - t("disruptions.fired_flash", { - name: fireTarget.item.name, - count: result.affectedTeamIds.length, - }), - ); - setFireTarget(null); - await reloadAudit(); - } catch (err) { - setFireError(toErrorMessage(err)); - } finally { - setFiring(false); - } - }, [apiClient, eventId, fireTarget, scope, selectedTeamIds, reloadAudit, t]); - - const fireDisabled = firing || (scope === "team" && selectedTeamIds.length === 0); - return ( ( @@ -207,80 +339,29 @@ export function DisruptionsPanel({ header: t("disruptions.col_affected"), cell: (r: DisruptionAuditRow) => String(r.targetTeamIds.length), }, + { + // [ADR-037] scheduled fire の注入予定時刻 (即時 fire は "-")。 + id: "scheduledFor", + header: t("disruptions.col_scheduled_for"), + cell: (r: DisruptionAuditRow) => r.scheduledFor ?? "-", + }, ]} items={audit} empty={{t("disruptions.audit_empty")}} /> - {fireTarget ? ( - setFireTarget(null)} - header={t("disruptions.fire_modal_header", { name: fireTarget.item.name })} - footer={ - - - - - - - } - > - - {fireError ? {fireError} : null} - {fireTarget.item.description} - -