+ Backups stream checkpointed Cosmos batches and commit verified work. Higher
+ concurrency can finish sooner and can also increase source Cosmos pressure.
+
+
+
+
+
+
+
+ setGuideOpen(true)}
+ >
+ About RU Boost
+
+
+ }
+ />
+ {sourceRuEnabled ? (
+
+ ) : null}
+
+
+
+
+
+
+ Source files stream through bounded chunks and durable per-file checkpoints.
+
+
+
+
+
+
+
+ About {bufferEstimate} MiB of file data can be buffered at once at most, excluding
+ Azure SDK overhead. Throttling temporarily lowers active transfers.
+
+
+ {help ||
+ 'Query a known SimpleChat Cosmos DB container, inspect one document, and save guarded JSON repairs with ETag protection.'}
+
+
+
+ This tool changes production Cosmos DB documents directly. Use it only for targeted repair
+ or investigation after the impact is understood.
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {!cosmos.unlocked ? (
+
+
+
The Cosmos DB JSON editor is locked.
+
+ Unlock it only after you know which container and document you need. Unlocking
+ records an acknowledgement in activity logs and enables live queries for this
+ page session.
+
+ {cosmos.document.editable ? null : (
+
+ This container is read-only in the Data Management editor.
+ You can inspect the JSON but cannot save changes here.
+
+ )}
+ {lastSaveSummary ? (
+
+ {lastSaveSummary}
+
+ ) : null}
+
+ ) : (
+
+ Open one selectable document at a time. Saves use the ETag
+ returned when the document was loaded.
+
+ )}
+
+
+ Incorrect edits can break authentication, workspaces, chat history, documents,
+ automations, or activity log integrity. Each unlock is recorded in activity logs.
+
+
+
Run targeted SELECT queries instead of broad container scans.
+
Do not change a document id or partition key value.
+
Review JSON carefully before saving; saves are audited in Activity Logs.
+
+ Saving replaces the selected Cosmos DB document with the JSON currently in the
+ editor. This action is audited and cannot be undone from this screen.
+
+
+
+
First changed paths
+
+ {localSummary.changedPaths.length
+ ? localSummary.changedPaths.slice(0, 20).join(', ')
+ : 'No value changes detected. Saving still replaces the document, which gives it a new ETag.'}
+
+ {generationError ? (
+
+ {generationError}
+
+ ) : null}
+
+
+ Restore review checks each backup against the encryption mode and key identity it was
+ written with. Turning encryption on or off, or replacing a key stored in settings, can
+ make earlier backups fail review until settings match again; Key Vault backups keep
+ the secret version they used.
+
+ {confirmingReplace ? (
+ }
+ tone={replaceSettingsKey ? 'danger' : 'primary'}
+ busy={generating}
+ onConfirm={() => void runGenerate()}
+ onClose={() => {
+ if (!generating) setConfirmingReplace(false);
+ }}
+ >
+
+ This changes the key used by future encrypted backups.
+
+
+ ) : null}
+ {dialog}
+
+ )}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/dataManagement/DataManagementGuides.tsx b/application/v2_ui/src/components/admin/dataManagement/DataManagementGuides.tsx
new file mode 100644
index 000000000..663cdf66c
--- /dev/null
+++ b/application/v2_ui/src/components/admin/dataManagement/DataManagementGuides.tsx
@@ -0,0 +1,185 @@
+// DataManagementGuides.tsx
+// The Backup & Recovery guides: setup, backup, migration, restore and RU Boost.
+
+import type { ReactNode } from 'react';
+import { GlassButton } from '../../ui/primitives';
+import { Modal } from '../../ui/Modal';
+import { DmNotice } from './DmShared';
+
+export type GuideId = 'setup' | 'backup' | 'migration' | 'restore' | 'ru-boost';
+
+export const GUIDE_TITLES: Record = {
+ setup: 'Set up Backup & Recovery',
+ backup: 'How backups work',
+ migration: 'How migration works',
+ restore: 'How restore works',
+ 'ru-boost': 'RU Boost permissions',
+};
+
+const OPERATIONAL_WARNING =
+ 'We suggest not running backups, restores, or migrations during your operational business hours. These jobs run inside the App Service environment and can affect application performance.';
+
+interface GuideSection {
+ title: string;
+ items: ReactNode[];
+}
+
+const GUIDE_CONTENT: Record = {
+ setup: {
+ intro: 'Use this order before an incident or cutover window. Prove backup first, then make restore or migration decisions from completed backup evidence.',
+ sections: [
+ {
+ title: 'Readiness order',
+ items: [
+ 'Use backup storage that is dedicated to backup artifacts and separate from Enhanced Citation source files.',
+ 'Generate the backup encryption key and protect it; Key Vault is the safer production storage location.',
+ 'Queue a manual full backup and inspect the Backup Inventory record before depending on scheduled backups.',
+ ],
+ },
+ {
+ title: 'Before restore or migration',
+ items: [
+ 'For migration, configure destination Cosmos DB, AI Search, and storage before selecting scope.',
+ 'For restore, start from Backup Inventory and stage the selected backup through the restore workflow.',
+ ],
+ },
+ ],
+ },
+ backup: {
+ intro: 'Backups write durable artifacts and job records that restore and migration workflows can inspect later. Full backups capture a complete selected snapshot; partial backups capture daily changes between full backups.',
+ sections: [
+ {
+ title: 'Storage and encryption',
+ items: [
+ 'Use a dedicated backup account and container so restore and migration artifacts never overwrite source documents.',
+ 'Keep backup encryption enabled for production and store generated keys in Key Vault when available.',
+ ],
+ },
+ {
+ title: 'Scope and timing',
+ items: [
+ 'Leave Cosmos DB and AI Search enabled unless you intentionally need a limited backup.',
+ 'Queue large backups outside business hours because App Service workers perform the work.',
+ ],
+ },
+ ],
+ },
+ migration: {
+ intro: 'Migration moves selected SimpleChat users, groups, public workspaces, and optional documents to another SimpleChat environment.',
+ sections: [
+ {
+ title: 'Run modes',
+ items: [
+ <>
+ Copy missing items only: safest for a first run because existing
+ destination records are not changed.
+ >,
+ <>
+ Catch up changed items: use after a completed migration to copy new
+ items and update migration-owned changes.
+ >,
+ <>
+ Make destination match source: use only during cutover when migrated
+ destination-only items should be removed.
+ >,
+ ],
+ },
+ {
+ title: 'Search coordination',
+ items: [
+ 'Freeze external destination writers before moving AI Search documents because SimpleChat cannot coordinate writers outside the app.',
+ ],
+ },
+ ],
+ },
+ restore: {
+ intro: 'Restore starts from Backup Inventory. A review checks the selected backup and the destination before a restore can be queued, and shows what each surface would restore.',
+ sections: [
+ {
+ title: 'Choose evidence',
+ items: [
+ 'Prefer full backups for restore decisions because partial backups depend on earlier backup history.',
+ 'Confirm the encryption key is still available before relying on encrypted artifacts.',
+ ],
+ },
+ {
+ title: 'Run safely',
+ items: [
+ 'Review warnings and missing surfaces before restoring AI Search or source document blobs.',
+ 'Run restore only in a maintenance window because restored records can affect live users and search results.',
+ ],
+ },
+ ],
+ },
+ 'ru-boost': {
+ intro: 'RU Boost temporarily raises eligible Cosmos DB throughput up to 10,000 RU/s for approved backup or migration windows, then restores the original capacity.',
+ sections: [
+ {
+ title: 'Cost and recovery behavior',
+ items: [
+ 'Raising throughput can increase Azure charges while the boost is active.',
+ 'The original capacity is restored after completion, failure, cancellation, or recovery.',
+ ],
+ },
+ {
+ title: 'Permission checks',
+ items: [
+ 'Data copy permissions prove the identity can create, read, and delete probe records in destination Cosmos containers.',
+ 'RU Boost permissions prove the identity can read and write Cosmos throughput settings through Azure Resource Manager.',
+ 'Management-plane permission is needed separately from data access.',
+ 'Use Test RU Boost before a cutover window; it validates capacity permissions separately from migration data access.',
+ 'Destination RU Boost also needs the destination subscription ID and resource group because those values are not part of the data-plane endpoint.',
+ ],
+ },
+ ],
+ },
+};
+
+/** One guide in a dialog. */
+export function DataManagementGuideDialog({ guide, onClose }: { guide: GuideId; onClose: () => void }) {
+ const content = GUIDE_CONTENT[guide];
+ return (
+
+ Close
+
+ }
+ >
+
+ {sectionHeading(
+ 'Connect the destination',
+ 'Configure the destination services this migration will write to. Tests send the current values inline and do not save settings.',
+ Database: {TARGET_COSMOS_DATABASE_NAME},
+ )}
+
+ {sectionHeading(
+ 'Choose who and what moves',
+ 'Select users, groups, and public workspaces. All uses the server count when the job starts; the browser never loads every record.',
+
+ {principalCount.includedKnown
+ ? `${formatNumber(principalCount.total)} principal scopes selected`
+ : countFailed
+ ? 'Some counts are unavailable'
+ : 'Counting principal scopes'}
+
+ {sectionHeading(
+ 'Choose what moves',
+ 'Set destination behavior, optional surfaces, performance limits, and resumability before the server review.',
+ )}
+
+ {sectionHeading(
+ 'Review migration evidence',
+ 'Run the server preflight review against the current plan and settings. The checkpoint must be ready and current before Confirm opens.',
+
+ {headline.text}
+ ,
+ )}
+
+ {sectionHeading(
+ 'Confirm and start migration',
+ 'Review the normalized plan summary, acknowledge the operational impact, then submit the durable migration job once.',
+ )}
+
+ {state.mode === 'mirror_with_deletions' ? (
+
+ Only migration-created destination items may be deleted. Unowned destination data is kept
+ and reported as a conflict.
+
+ ) : null}
+ {state.mode === 'mirror_with_deletions' ? (
+
+
+ Leaving a surface out creates backups that are incomplete for restore or
+ migration.
+
+
+
+
+
+
+ Source files can be included once Enhanced Citations is
+ on.
+
+
+ onNavigate(LINKED_SECTION_IDS.enhancedCitations)
+ }
+ >
+ Open Enhanced Citations
+
+
+
+
+ Use a storage account dedicated to backups. While Enhanced Citations is on, saving is
+ refused if backup storage uses the same connection string or Blob endpoint.
+
+
+ {authType === 'connection_string' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
Test storage
+
+ Sends the current values without saving. The test creates the container if
+ it is missing, and the dedicated-storage check compares against the saved
+ Enhanced Citations settings.
+
+
+ Earlier backups record the container, path prefix and storage identity they were
+ written with. If any of those change, restore review rejects those backups until the
+ settings are changed back.
+
+
+ )}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/dataManagement/DmField.tsx b/application/v2_ui/src/components/admin/dataManagement/DmField.tsx
new file mode 100644
index 000000000..3c28925ba
--- /dev/null
+++ b/application/v2_ui/src/components/admin/dataManagement/DmField.tsx
@@ -0,0 +1,91 @@
+// DmField.tsx
+// One Backup & Recovery setting, drawn with the shared Admin Settings controls.
+//
+// The value comes from the data-management store rather than the page's draft, because
+// these settings are a separate document with their own save. Everything an administrator
+// sees -- label, help, error, the secret field's stored/replace/remove states -- is the same
+// control every other setting on the page uses.
+
+import { useMemo, type ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { DM_DEFAULTS, type DmEditableKey } from '../../../lib/dataManagement';
+import { DM_FIELDS, toAdminField, type DmFieldDef } from '../../../lib/dataManagementFields';
+import { useDataManagementStore } from '../../../stores/dataManagementStore';
+import { SettingField } from '../fields';
+import { SecretField } from '../SecretField';
+
+export type DmFieldEmphasis = 'primary' | 'dependent';
+
+/** Read one editable value: the unsaved edit, else the saved value, else the default. */
+export function useDmValue(key: DmEditableKey): unknown {
+ return useDataManagementStore((state) => {
+ if (Object.prototype.hasOwnProperty.call(state.draft, key)) return state.draft[key];
+ const saved = state.settings?.[key];
+ return saved === undefined || saved === null ? DM_DEFAULTS[key] : saved;
+ });
+}
+
+export function DmField({
+ dmKey,
+ disabled,
+ emphasis,
+ overrides,
+ after,
+}: {
+ dmKey: DmEditableKey;
+ disabled?: boolean;
+ /** `primary` frames the switch a card hangs off; `dependent` indents a setting under one. */
+ emphasis?: DmFieldEmphasis;
+ /** Per-use changes to the declared label, help or notice. */
+ overrides?: Partial>;
+ /** Content drawn directly beneath the control, inside its emphasis frame. */
+ after?: ReactNode;
+}) {
+ const declared = DM_FIELDS[dmKey];
+ const value = useDmValue(dmKey);
+ const stored = useDataManagementStore((state) => state.settings?.[dmKey]);
+ const error = useDataManagementStore((state) => state.fieldErrors[dmKey]);
+ const saving = useDataManagementStore((state) => state.saving);
+ const setValue = useDataManagementStore((state) => state.setValue);
+
+ const field = useMemo(
+ () => (declared ? toAdminField({ ...declared, ...overrides, dmKey }) : null),
+ [declared, overrides, dmKey],
+ );
+ if (!field) return null;
+
+ const locked = Boolean(disabled || saving);
+ const onChange = (next: unknown) => setValue(dmKey, next);
+ const control =
+ field.type === 'secret' ? (
+
+ ) : (
+
+ );
+
+ // Each field is its own size container. The shared field layout switches to a label
+ // column beside the control once its container is wide, and measured against the whole
+ // card that would overflow a field placed in a grid cell; measured against itself, a
+ // field sits beside its label in a full row and stacks in a narrow cell.
+ return (
+
+ {control}
+ {after}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/dataManagement/DmShared.tsx b/application/v2_ui/src/components/admin/dataManagement/DmShared.tsx
new file mode 100644
index 000000000..f0ea94569
--- /dev/null
+++ b/application/v2_ui/src/components/admin/dataManagement/DmShared.tsx
@@ -0,0 +1,576 @@
+// DmShared.tsx
+// Building blocks shared by the Backup & Recovery cards.
+//
+// Nine cards make up the group, and five of them are workbenches. Drawing a status chip, a
+// review check, a pager or a typed confirmation the same way in every one is what lets the
+// group read as one surface rather than nine, and what keeps it reading like the rest of
+// Admin Settings: the tones, borders and type sizes here are the ones the shared field
+// controls and the Model Catalog workbench already use.
+
+import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from 'react';
+import { clsx } from 'clsx';
+import {
+ AlertCircle,
+ CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
+ CircleSlash,
+ Info,
+ Loader2,
+ OctagonX,
+ TriangleAlert,
+} from 'lucide-react';
+import { GlassButton } from '../../ui/primitives';
+import { inputClass } from '../fields';
+import {
+ humanizeToken,
+ jobStatusTone,
+ MIGRATION_STEP_LABELS,
+ isMigrationStep,
+ type ReviewCheckView,
+ type StatusTone,
+} from '../../../lib/dataManagementLogic';
+
+/** Props every Backup & Recovery card takes from the settings page. */
+export interface DmCardProps {
+ /** The schema's help text for the section. */
+ help?: string;
+ /** Move the page to another section, clearing a filter that hides it. */
+ onNavigate: (sectionId: string) => void;
+ /** True while the page is saving, which locks every editable control. */
+ disabled?: boolean;
+}
+
+/** The short description a card opens with, as the Model Catalog's does. */
+export function DmIntro({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+export type NoticeTone = 'info' | 'warning' | 'danger' | 'success';
+
+const NOTICE_STYLE: Record = {
+ info: { className: 'border-edge bg-surface-2 text-text-2', Icon: Info },
+ warning: { className: 'border-warn/40 bg-warn-soft text-warn', Icon: TriangleAlert },
+ danger: { className: 'border-danger/40 bg-danger-soft text-danger', Icon: AlertCircle },
+ success: { className: 'border-ok/40 bg-ok-soft text-ok', Icon: CheckCircle2 },
+};
+
+/**
+ * A standing callout: guidance, a risk, or the outcome of an action.
+ *
+ * `role` is left to the caller, because a risk that is always on screen is not an alert,
+ * while the result of an action an administrator just took is.
+ */
+export function DmNotice({
+ tone = 'info',
+ title,
+ children,
+ action,
+ role,
+ className,
+}: {
+ tone?: NoticeTone;
+ title?: string;
+ children?: ReactNode;
+ action?: ReactNode;
+ role?: 'alert' | 'status' | 'note';
+ className?: string;
+}) {
+ const { className: toneClass, Icon } = NOTICE_STYLE[tone];
+ return (
+
+
+
+ {title ?
{title}
: null}
+ {children ?
{children}
: null}
+
+ {action ?
{action}
: null}
+
+ );
+}
+
+const PILL_TONE: Record = {
+ ok: 'border-ok/40 bg-ok-soft text-ok',
+ warn: 'border-warn/40 bg-warn-soft text-warn',
+ danger: 'border-danger/40 bg-danger-soft text-danger',
+ active: 'border-accent/40 bg-accent-soft text-accent',
+ neutral: 'border-edge-strong bg-surface-2 text-text-2',
+};
+
+/** A job or backup status, in the same words and tones wherever it appears. */
+export function DmStatusPill({ status, className }: { status: unknown; className?: string }) {
+ const text = humanizeToken(status) || 'Unknown';
+ const tone = jobStatusTone(status);
+ return (
+
+ {tone === 'active' ? (
+
+ ) : null}
+ {text}
+
+ );
+}
+
+/** A label chip, for a type or mode rather than a job state. */
+export function DmChip({ children, tone = 'neutral' }: { children: ReactNode; tone?: StatusTone }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Label and value tiles: counts, sizes, times. */
+export function DmMetricGrid({
+ items,
+ className,
+ label,
+}: {
+ items: Array<{ label: string; value: ReactNode }>;
+ className?: string;
+ label?: string;
+}) {
+ if (!items.length) return null;
+ return (
+
+ {items.map((item) => (
+
+
{item.label}
+
+ {item.value}
+
+
+ ))}
+
+ );
+}
+
+const CHECK_STYLE = {
+ pass: { Icon: CheckCircle2, className: 'text-ok', label: 'Passed' },
+ warning: { Icon: TriangleAlert, className: 'text-warn', label: 'Warning' },
+ block: { Icon: OctagonX, className: 'text-danger', label: 'Blocked' },
+} as const;
+
+/**
+ * Server-owned review evidence.
+ *
+ * A migration check names the wizard step that fixes it, and when it fails `onFixStep` turns
+ * that into a way back to the step.
+ */
+export function DmEvidenceChecks({
+ checks,
+ onFixStep,
+ label = 'Review checks',
+}: {
+ checks: ReviewCheckView[];
+ onFixStep?: (step: string) => void;
+ label?: string;
+}) {
+ if (!checks.length) {
+ return
+ );
+}
+
+/** A labelled date input for a workbench's filter row. */
+export function DmDateFilter({
+ label,
+ value,
+ onChange,
+ disabled,
+ invalid,
+}: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+ invalid?: boolean;
+}) {
+ const id = useId();
+ return (
+
+
+ onChange(event.target.value)}
+ />
+
+ );
+}
+
+/** Whether a workbench detail pane is below the list rather than beside it. */
+export function isStacked(list: HTMLElement | null, detail: HTMLElement | null): boolean {
+ if (!list || !detail) return false;
+ return Math.abs(list.getBoundingClientRect().top - detail.getBoundingClientRect().top) > 16;
+}
+
+/**
+ * The list-beside-detail layout the Model Catalog uses.
+ *
+ * Side by side once the card is wide enough, stacked below that, with each pane scrolling
+ * on its own so a long list does not push the detail out of view.
+ */
+export function DmWorkbench({
+ list,
+ detail,
+ listLabel,
+ detailRef,
+}: {
+ list: ReactNode;
+ detail: ReactNode;
+ listLabel: string;
+ detailRef?: RefObject;
+}) {
+ return (
+
+
+ {list}
+
+
+ {detail}
+
+
+ );
+}
+
+/** An empty pane with a sentence of guidance. */
+export function DmEmpty({ title, children }: { title: string; children?: ReactNode }) {
+ return (
+
+
+
{title}
+ {children ? (
+
{children}
+ ) : null}
+
+ );
+}
+
+/**
+ * Whether an element has been on screen at least once.
+ *
+ * The inventory and job lists run several Cosmos queries each, so a card loads them once an
+ * administrator scrolls it into view rather than whenever the settings page opens.
+ */
+export function useVisibleOnce(ref: RefObject, rootMargin = '200px'): boolean {
+ const [visible, setVisible] = useState(false);
+ useEffect(() => {
+ if (visible) return;
+ const element = ref.current;
+ if (!element || typeof IntersectionObserver === 'undefined') {
+ setVisible(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref, rootMargin, visible]);
+ return visible;
+}
+
+/**
+ * Run a callback on an interval while `delay` is a number, stopping when it is null.
+ *
+ * The latest callback is the one called, so a poll never acts on stale props, and the timer
+ * is cleared on unmount so nothing is fetched after the card is gone.
+ */
+export function useInterval(callback: () => void, delay: number | null): void {
+ const saved = useRef(callback);
+ saved.current = callback;
+ useEffect(() => {
+ if (delay === null) return;
+ const timer = window.setInterval(() => saved.current(), delay);
+ return () => window.clearInterval(timer);
+ }, [delay]);
+}
+
+/** The current time, refreshed on an interval, for countdowns and "2m ago" readouts. */
+export function useNow(intervalMs = 1000, active = true): number {
+ const [now, setNow] = useState(() => Date.now());
+ useInterval(() => setNow(Date.now()), active ? intervalMs : null);
+ return now;
+}
diff --git a/application/v2_ui/src/components/admin/dataManagement/useBackupSummary.ts b/application/v2_ui/src/components/admin/dataManagement/useBackupSummary.ts
new file mode 100644
index 000000000..d8f2320e8
--- /dev/null
+++ b/application/v2_ui/src/components/admin/dataManagement/useBackupSummary.ts
@@ -0,0 +1,90 @@
+// useBackupSummary.ts
+// Both the readiness and backup cards need one latest-backup read, so this hook shares it.
+//
+// The read is cached per page visit and per backups revision. The store's epoch advances
+// each time the page is left, which is what keeps a later visit from reusing this visit's
+// summary after its revision counter starts again from zero.
+
+import { useEffect, useState } from 'react';
+import {
+ listBackups,
+ readHistoryFailure,
+ type BackupGlobalSummary,
+ type HistoryFailure,
+} from '../../../lib/dataManagement';
+import { currentEpoch, useDataManagementStore } from '../../../stores/dataManagementStore';
+
+export interface BackupSummaryState {
+ summary: BackupGlobalSummary | null;
+ loading: boolean;
+ failure: HistoryFailure | null;
+}
+
+let cachedKey: string | null = null;
+let cachedSummary: BackupGlobalSummary | null = null;
+let cachedFailure: HistoryFailure | null = null;
+let inFlight: {
+ key: string;
+ promise: Promise;
+} | null = null;
+
+function loadSummary(key: string): Promise {
+ if (inFlight?.key === key) return inFlight.promise;
+
+ const promise = listBackups({ status: 'available', scheduled: 'all', pageSize: 1 }, null)
+ .then((page) => {
+ cachedKey = key;
+ cachedSummary = page.summary ?? {};
+ cachedFailure = null;
+ return cachedSummary;
+ })
+ .catch((error: unknown) => {
+ cachedKey = key;
+ cachedSummary = null;
+ cachedFailure = readHistoryFailure(error, 'Backup summary could not be loaded.');
+ throw cachedFailure;
+ })
+ .finally(() => {
+ if (inFlight?.key === key) inFlight = null;
+ });
+
+ inFlight = { key, promise };
+ return promise;
+}
+
+export function useBackupSummary(enabled: boolean): BackupSummaryState {
+ const backupsRevision = useDataManagementStore((state) => state.backupsRevision);
+ const key = `${currentEpoch()}:${backupsRevision}`;
+ const [state, setState] = useState(() => ({
+ summary: cachedKey === key ? cachedSummary : null,
+ loading: false,
+ failure: cachedKey === key ? cachedFailure : null,
+ }));
+
+ useEffect(() => {
+ if (!enabled) return undefined;
+
+ let active = true;
+ if (cachedKey === key) {
+ setState({ summary: cachedSummary, loading: false, failure: cachedFailure });
+ return () => {
+ active = false;
+ };
+ }
+
+ setState((current) => ({ ...current, loading: true, failure: null }));
+ loadSummary(key)
+ .then((summary) => {
+ if (active) setState({ summary, loading: false, failure: null });
+ })
+ .catch((failure: HistoryFailure) => {
+ if (active) setState({ summary: null, loading: false, failure });
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [enabled, key]);
+
+ return state;
+}
diff --git a/application/v2_ui/src/components/admin/dataManagement/useSaveFirst.tsx b/application/v2_ui/src/components/admin/dataManagement/useSaveFirst.tsx
new file mode 100644
index 000000000..8efd580e0
--- /dev/null
+++ b/application/v2_ui/src/components/admin/dataManagement/useSaveFirst.tsx
@@ -0,0 +1,88 @@
+// useSaveFirst.tsx
+// Run an action that needs saved backup settings, saving them first when they are not.
+//
+// Queueing a backup, running retention cleanup, reviewing or queueing a restore and
+// starting a migration all act on the *saved* data-management settings: the server reads
+// them again, and the review fingerprints are checked against them. The classic page saves
+// its settings before each of these, and so does this.
+//
+// One case needs the administrator: backup storage is validated against the Enhanced
+// Citations storage in the saved main settings. When those have unsaved edits, saving only
+// the backup settings would check them against values about to change, so the
+// administrator is asked to save everything first.
+
+import { useCallback, useRef, useState, type ReactNode } from 'react';
+import { Save } from 'lucide-react';
+import { ConfirmDialog } from '../../ui/ConfirmDialog';
+import { toast } from '../../../stores/toastStore';
+import { useDataManagementStore, type ReadinessPurpose } from '../../../stores/dataManagementStore';
+
+export function useSaveFirst(): {
+ /** Resolves true when the action may go ahead against saved settings. */
+ ensure: (purpose: ReadinessPurpose) => Promise;
+ /** The confirmation shown when other unsaved settings must be saved first. */
+ dialog: ReactNode;
+} {
+ const [prompt, setPrompt] = useState<{ message: string; purpose: ReadinessPurpose } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const resolver = useRef<((ready: boolean) => void) | null>(null);
+
+ const settle = (ready: boolean) => {
+ resolver.current?.(ready);
+ resolver.current = null;
+ setPrompt(null);
+ };
+
+ const ensure = useCallback(async (purpose: ReadinessPurpose) => {
+ const result = await useDataManagementStore.getState().ensureReadyFor(purpose);
+ if (result.ok) return true;
+ if (result.reason === 'save-all-required') {
+ resolver.current?.(false);
+ return new Promise((resolve) => {
+ resolver.current = resolve;
+ setPrompt({ message: result.message, purpose });
+ });
+ }
+ toast.error(result.message);
+ return false;
+ }, []);
+
+ const confirm = async () => {
+ if (!prompt) return;
+ const handler = useDataManagementStore.getState().saveAllHandler;
+ setBusy(true);
+ try {
+ const saved = handler ? await handler() : false;
+ if (!saved) {
+ settle(false);
+ return;
+ }
+ const again = await useDataManagementStore.getState().ensureReadyFor(prompt.purpose);
+ if (!again.ok) toast.error(again.message);
+ settle(again.ok);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const dialog = prompt ? (
+ }
+ tone="primary"
+ busy={busy}
+ onConfirm={() => void confirm()}
+ onClose={() => {
+ if (!busy) settle(false);
+ }}
+ >
+
+ Everything in the Save bar is saved, then the action continues.
+
+
+ ) : null;
+
+ return { ensure, dialog };
+}
diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts
index 34914c5ff..64264b2b0 100644
--- a/application/v2_ui/src/lib/adminFields.ts
+++ b/application/v2_ui/src/lib/adminFields.ts
@@ -226,6 +226,13 @@ export interface AdminField {
version_key?: string;
/** Component fields only: which bespoke widget to render. */
component?: string;
+ /**
+ * Extra words the page search matches on.
+ *
+ * A component can hold a whole workbench behind one label, so these name what is
+ * inside it -- "retention" finds the backup schedule that contains it.
+ */
+ keywords?: string[];
/** Connection test components only: which `test_connection` branch to call. */
test_type?: string;
/**
@@ -1073,6 +1080,7 @@ export function fieldSearchText(field: AdminField): string {
field.help ?? '',
field.notice ?? '',
field.component ?? '',
+ ...(field.keywords ?? []),
// A label that changes with the configuration is still the same setting, and an
// administrator may search for either name.
...(field.label_variants ?? []).flatMap((variant) => [variant.label ?? '', variant.help ?? '']),
diff --git a/application/v2_ui/src/lib/dataManagement.ts b/application/v2_ui/src/lib/dataManagement.ts
new file mode 100644
index 000000000..a633ebd42
--- /dev/null
+++ b/application/v2_ui/src/lib/dataManagement.ts
@@ -0,0 +1,926 @@
+// dataManagement.ts
+// Types, constants and API calls for Admin Settings > Backup & Recovery.
+//
+// Backup, restore, migration, the Cosmos editor and job history all talk to the classic
+// admin API under `/api/admin/data-management`. Every route there is admin-only and
+// already returns sanitized payloads, so the V2 surface reuses it rather than adding a
+// second set of endpoints that could drift from the first.
+//
+// The data-management settings are not part of the main settings document. They live in
+// their own Cosmos document and are saved as one whole object, which is why this module
+// carries its own list of editable keys (`DM_EDITABLE_KEYS`): it mirrors what the classic
+// page sends, so a save never writes read-only status fields back into the document.
+
+import { api, apiUrl, ApiError } from './apiClient';
+import { SECRET_PLACEHOLDER } from './adminFields';
+
+export const DM_API = '/api/admin/data-management';
+
+/** Typed phrases the server checks verbatim. Mirrors `functions_data_management.py`. */
+export const RESTORE_OVERWRITE_PHRASE = 'RESTORE WITH OVERWRITE';
+export const MIRROR_CONFIRMATION_PHRASE = 'MAKE DESTINATION MATCH SOURCE';
+export const COSMOS_EDITOR_SAVE_PHRASE = 'I understand this can damage system data';
+
+/** The redaction placeholder the data-management API uses, the same one admin settings uses. */
+export const DM_REDACTED = SECRET_PLACEHOLDER;
+
+/** Live job views refresh this often while a job is queued or running. */
+export const JOB_POLL_INTERVAL_MS = 2000;
+
+export const TARGET_COSMOS_DATABASE_NAME = 'SimpleChat';
+export const MIGRATION_MAX_SELECTED_IDS = 2000;
+export const MIGRATION_CATALOG_PAGE_SIZE = 25;
+export const COSMOS_EDITOR_MAX_PAGE_SIZE = 100;
+export const COSMOS_EDITOR_MAX_QUERY_LENGTH = 4000;
+export const HISTORY_MAX_RANGE_DAYS = 366;
+export const HISTORY_PAGE_SIZES = [10, 25, 50, 100] as const;
+export const RETENTION_MAX_DAYS = 3650;
+export const RETENTION_UNIT_DAYS = { days: 1, weeks: 7, months: 30, years: 365 } as const;
+export type RetentionUnit = keyof typeof RETENTION_UNIT_DAYS;
+
+/** Statuses the failure-only manifest download asks for, as the classic page does. */
+export const MANIFEST_FAILURE_STATUSES = 'failed,missing,collision';
+
+export const ACTIVE_JOB_STATUSES: ReadonlySet = new Set(['queued', 'running']);
+export const TERMINAL_JOB_STATUSES: ReadonlySet = new Set([
+ 'completed',
+ 'completed_with_warnings',
+ 'failed',
+ 'canceled',
+]);
+
+export type JobOperation = 'backup' | 'restore' | 'migration' | 'dry_run';
+export type BackupType = 'full' | 'partial';
+export type JobStatus =
+ 'queued' | 'running' | 'completed' | 'completed_with_warnings' | 'failed' | 'canceled';
+
+export const MIGRATION_TARGET_TYPES = ['users', 'groups', 'public_workspaces'] as const;
+export type MigrationTargetType = (typeof MIGRATION_TARGET_TYPES)[number];
+export type MigrationScopeMode = 'none' | 'selected' | 'all';
+export type MigrationMode = 'new_only' | 'delta_upsert' | 'mirror_with_deletions';
+export type RestorePolicy = 'create_only' | 'overwrite_existing';
+
+// ---------------------------------------------------------------------------------------
+// Settings
+// ---------------------------------------------------------------------------------------
+
+/**
+ * Every key the classic page sends on save, in the same order (`collectSettings()` in
+ * `admin_data_management.js`). The parity test reads both lists and fails if they drift.
+ */
+export const DM_EDITABLE_KEYS = [
+ 'enabled',
+ 'full_backup_frequency',
+ 'scheduled_time_utc',
+ 'retention_value',
+ 'retention_unit',
+ 'retention_days',
+ 'partial_backups_enabled',
+ 'low_impact_mode',
+ 'include_cosmos',
+ 'include_ai_search',
+ 'include_source_blobs',
+ 'backup_storage_authentication_type',
+ 'backup_storage_blob_endpoint',
+ 'backup_storage_container_name',
+ 'backup_storage_connection_string',
+ 'backup_storage_path_prefix',
+ 'encryption_enabled',
+ 'backup_max_parallel_operations',
+ 'backup_retry_count',
+ 'backup_blob_max_parallel_operations',
+ 'backup_blob_chunk_size_mib',
+ 'backup_blob_retry_count',
+ 'backup_capacity_failure_policy',
+ 'backup_temporary_source_ru_enabled',
+ 'backup_temporary_source_ru',
+ 'target_cosmos_authentication_type',
+ 'target_cosmos_endpoint',
+ 'target_cosmos_database_name',
+ 'target_cosmos_key',
+ 'target_cosmos_subscription_id',
+ 'target_cosmos_resource_group',
+ 'target_ai_search_authentication_type',
+ 'target_ai_search_endpoint',
+ 'target_ai_search_key',
+ 'target_enhanced_citations_storage_authentication_type',
+ 'target_enhanced_citations_storage_blob_endpoint',
+ 'target_enhanced_citations_storage_connection_string',
+ 'migration_max_parallel_operations',
+ 'migration_retry_count',
+ 'migration_skip_recent_within_hours',
+ 'migration_temporary_destination_ru_enabled',
+ 'migration_temporary_destination_ru',
+] as const;
+
+export type DmEditableKey = (typeof DM_EDITABLE_KEYS)[number];
+
+/** Editable credentials. Sent back as the placeholder when untouched, which keeps them. */
+export const DM_SECRET_KEYS: readonly DmEditableKey[] = [
+ 'backup_storage_connection_string',
+ 'target_cosmos_key',
+ 'target_ai_search_key',
+ 'target_enhanced_citations_storage_connection_string',
+];
+
+/** Keys derived from others rather than edited directly. */
+export const DM_DERIVED_KEYS: readonly DmEditableKey[] = ['retention_days', 'target_cosmos_database_name'];
+
+/** Mirrors `DATA_MANAGEMENT_DEFAULT_SETTINGS` for the editable keys. */
+export const DM_DEFAULTS: Record = {
+ enabled: false,
+ full_backup_frequency: 'weekly',
+ scheduled_time_utc: '03:00',
+ retention_value: 30,
+ retention_unit: 'days',
+ retention_days: 30,
+ partial_backups_enabled: true,
+ low_impact_mode: true,
+ include_cosmos: true,
+ include_ai_search: true,
+ include_source_blobs: true,
+ backup_storage_authentication_type: 'managed_identity',
+ backup_storage_blob_endpoint: '',
+ backup_storage_container_name: 'simplechat-backups',
+ backup_storage_connection_string: '',
+ backup_storage_path_prefix: 'simplechat-backups',
+ encryption_enabled: true,
+ backup_max_parallel_operations: 4,
+ backup_retry_count: 5,
+ backup_blob_max_parallel_operations: 4,
+ backup_blob_chunk_size_mib: 8,
+ backup_blob_retry_count: 5,
+ backup_capacity_failure_policy: 'continue_without_boost',
+ backup_temporary_source_ru_enabled: false,
+ backup_temporary_source_ru: 10000,
+ target_cosmos_authentication_type: 'managed_identity',
+ target_cosmos_endpoint: '',
+ target_cosmos_database_name: TARGET_COSMOS_DATABASE_NAME,
+ target_cosmos_key: '',
+ target_cosmos_subscription_id: '',
+ target_cosmos_resource_group: '',
+ target_ai_search_authentication_type: 'managed_identity',
+ target_ai_search_endpoint: '',
+ target_ai_search_key: '',
+ target_enhanced_citations_storage_authentication_type: 'managed_identity',
+ target_enhanced_citations_storage_blob_endpoint: '',
+ target_enhanced_citations_storage_connection_string: '',
+ migration_max_parallel_operations: 8,
+ migration_retry_count: 5,
+ migration_skip_recent_within_hours: 0,
+ migration_temporary_destination_ru_enabled: false,
+ migration_temporary_destination_ru: 10000,
+};
+
+/** Inclusive bounds the classic controls enforce, matching the server's clamps. */
+export const DM_NUMBER_BOUNDS: Partial> = {
+ backup_max_parallel_operations: { min: 1, max: 16 },
+ backup_retry_count: { min: 1, max: 10 },
+ backup_blob_max_parallel_operations: { min: 1, max: 8 },
+ backup_blob_chunk_size_mib: { min: 1, max: 16 },
+ backup_blob_retry_count: { min: 1, max: 10 },
+ backup_temporary_source_ru: { min: 1000, max: 10000, step: 1000 },
+ migration_max_parallel_operations: { min: 1, max: 32 },
+ migration_retry_count: { min: 1, max: 10 },
+ migration_skip_recent_within_hours: { min: 0, max: 8760 },
+ migration_temporary_destination_ru: { min: 1000, max: 10000, step: 1000 },
+};
+
+/**
+ * The sanitized settings document the API returns.
+ *
+ * Besides the editable keys it carries read-only status (`next_*`, `last_*`), the key
+ * storage mode, the redacted key reference, and feature context the server resolves from
+ * the main settings (`enhanced_citations_enabled`, the Key Vault flags).
+ */
+export interface DmSettings extends Partial> {
+ encryption_key_reference?: string;
+ encryption_key_storage?: 'key_vault' | 'settings' | 'not_configured' | string;
+ next_full_backup_run_at?: string | null;
+ next_partial_backup_run_at?: string | null;
+ last_full_backup_completed_at?: string | null;
+ last_partial_backup_completed_at?: string | null;
+ last_settings_update_at?: string | null;
+ backup_retention_cleanup_last_run_at?: string | null;
+ retention_keep_latest_full?: boolean;
+ enhanced_citations_enabled?: boolean;
+ key_vault_secret_storage_enabled?: boolean;
+ key_vault_name_configured?: boolean;
+ include_source_blobs_manageable?: boolean;
+ operational_business_hours_warning?: string;
+ default_scheduled_time_utc?: string;
+ partial_backup_frequency_label?: string;
+ [key: string]: unknown;
+}
+
+export type DmDraft = Partial>;
+
+interface SettingsResponse {
+ success?: boolean;
+ settings: DmSettings;
+}
+
+export function loadDataManagementSettings(signal?: AbortSignal): Promise {
+ return api
+ .get(`${DM_API}/settings`, signal)
+ .then((response) => response.settings ?? {});
+}
+
+/** Save the whole editable object. The response is the normalized, re-redacted document. */
+export function saveDataManagementSettings(payload: Record): Promise {
+ return api
+ .put(`${DM_API}/settings`, payload)
+ .then((response) => response.settings ?? {});
+}
+
+/** Generate a new backup encryption key. Returns the sanitized settings with the new key metadata. */
+export function generateEncryptionKey(): Promise {
+ return api
+ .post(`${DM_API}/encryption-key`, {})
+ .then((response) => response.settings ?? {});
+}
+
+// ---------------------------------------------------------------------------------------
+// Connection tests
+// ---------------------------------------------------------------------------------------
+
+export interface StorageTestResult {
+ success: boolean;
+ container_name?: string;
+ container_exists?: boolean;
+ container_created?: boolean;
+ authentication_type?: string;
+}
+
+export function testBackupStorage(settings: Record): Promise {
+ // The classic page always asks for the container to be created when it is missing,
+ // and says so in its result. Kept identical so both interfaces leave storage the same.
+ return api.post(`${DM_API}/storage/test`, { settings, create_container: true });
+}
+
+export interface TargetCosmosTestResult {
+ success: boolean;
+ database_name?: string;
+ authentication_type?: string;
+ migration_access?: { container_count?: number } | null;
+}
+
+export function testTargetCosmos(
+ settings: Record,
+ migrationPlan: MigrationPlan,
+): Promise {
+ return api.post(`${DM_API}/target/cosmos/test`, {
+ settings,
+ migration_plan: migrationPlan,
+ });
+}
+
+export interface RuBoostTestResult {
+ success: boolean;
+ target_ru?: number;
+ database_mode?: string;
+ database_current_ru?: number;
+ targets?: Array<{ scope?: string; container_name?: string; mode?: string; current_ru?: number }>;
+}
+
+export function testTargetCosmosRuBoost(
+ settings: Record,
+ migrationPlan: MigrationPlan,
+): Promise {
+ return api.post(`${DM_API}/target/cosmos/ru-boost/test`, {
+ settings,
+ migration_plan: migrationPlan,
+ });
+}
+
+export interface TargetSearchTestResult {
+ success: boolean;
+ expected_indexes?: string[];
+ existing_indexes?: string[];
+ missing_indexes?: string[];
+}
+
+export function testTargetSearch(settings: Record): Promise {
+ return api.post(`${DM_API}/target/search/test`, { settings });
+}
+
+export interface TargetStorageTestResult {
+ success: boolean;
+ containers?: Array<{ container_name?: string; container_exists?: boolean; container_created?: boolean }>;
+}
+
+export function testTargetEnhancedCitationStorage(
+ settings: Record,
+): Promise {
+ return api.post(`${DM_API}/target/enhanced-citation-storage/test`, {
+ settings,
+ create_containers: true,
+ });
+}
+
+// ---------------------------------------------------------------------------------------
+// History (backups and jobs)
+// ---------------------------------------------------------------------------------------
+
+export interface HistoryPagination {
+ page_size?: number;
+ returned_count?: number;
+ has_more?: boolean;
+ next_token?: string | null;
+}
+
+export interface HistoryFilters {
+ status?: string;
+ scheduled?: 'all' | 'scheduled' | 'manual';
+ createdFrom?: string;
+ createdTo?: string;
+ pageSize?: number;
+}
+
+export interface BackupFilters extends HistoryFilters {
+ backupType?: '' | BackupType;
+}
+
+export interface JobFilters extends HistoryFilters {
+ operation?: '' | JobOperation;
+}
+
+/** One row of the backup inventory, as `sanitize_data_management_backup_for_admin` builds it. */
+export interface BackupRow {
+ id: string;
+ backup_type?: BackupType | string | null;
+ status?: JobStatus | string;
+ created_at?: string | null;
+ completed_at?: string | null;
+ scheduled?: boolean;
+ manifest_path?: string | null;
+ base_prefix?: string | null;
+ artifact_count?: number;
+ bytes?: number;
+ record_count?: number;
+ blob_count?: number;
+ warning_count?: number;
+ encrypted?: boolean;
+ last_message?: string | null;
+ can_delete?: boolean;
+}
+
+export interface BackupGlobalSummary {
+ full?: number;
+ partial?: number;
+ available?: number;
+ running?: number;
+ failed?: number;
+ total?: number;
+ latest_full?: BackupRow | null;
+ latest_partial?: BackupRow | null;
+}
+
+export interface BackupPage {
+ summary: BackupGlobalSummary;
+ backups: BackupRow[];
+ pagination: HistoryPagination;
+}
+
+/**
+ * Why a history list could not load.
+ *
+ * The server distinguishes a missing Cosmos index (an operator has to run maintenance) from
+ * a throttled or transient read (worth retrying), and the message it sends is already safe
+ * to show.
+ */
+export interface HistoryFailure {
+ message: string;
+ maintenanceRequired: boolean;
+ retryable: boolean;
+}
+
+export function readHistoryFailure(error: unknown, fallback: string): HistoryFailure {
+ if (error instanceof ApiError) {
+ const payload = (error.payload && typeof error.payload === 'object' ? error.payload : {}) as Record<
+ string,
+ unknown
+ >;
+ return {
+ message: error.message || fallback,
+ maintenanceRequired: payload.maintenance_required === true,
+ retryable: payload.retryable === true,
+ };
+ }
+ return {
+ message: error instanceof Error && error.message ? error.message : fallback,
+ maintenanceRequired: false,
+ retryable: false,
+ };
+}
+
+function historyQuery(filters: HistoryFilters, token: string | null, extra: Record): string {
+ const params = new URLSearchParams();
+ params.set('page_size', String(filters.pageSize ?? 25));
+ if (filters.status) params.set('status', filters.status);
+ params.set('scheduled', filters.scheduled ?? 'all');
+ if (filters.createdFrom && filters.createdTo) {
+ params.set('created_from', filters.createdFrom);
+ params.set('created_to', filters.createdTo);
+ }
+ for (const [key, value] of Object.entries(extra)) {
+ if (value) params.set(key, value);
+ }
+ if (token) params.set('continuation_token', token);
+ return params.toString();
+}
+
+export function listBackups(
+ filters: BackupFilters,
+ token: string | null,
+ signal?: AbortSignal,
+): Promise {
+ const query = historyQuery(filters, token, { backup_type: filters.backupType ?? '' });
+ return api
+ .get(`${DM_API}/backups?${query}`, signal)
+ .then((response) => ({
+ summary: response.summary ?? {},
+ backups: Array.isArray(response.backups) ? response.backups : [],
+ pagination: response.pagination ?? {},
+ }));
+}
+
+export interface RetentionCleanupResult {
+ success?: boolean;
+ manual_execution?: boolean;
+ cutoff_at?: string;
+ retention_days?: number;
+ retention_value?: number;
+ retention_unit?: string;
+ protected_latest_full_backup_id?: string;
+ candidate_count?: number;
+ deleted_count?: number;
+ errors?: Array<{ job_id?: string; error?: string }>;
+}
+
+/**
+ * Run retention cleanup now.
+ *
+ * A cleanup that deleted some backups but failed on others answers 400 with the partial
+ * result attached, so that case is read off the error rather than lost.
+ */
+export async function runRetentionCleanup(): Promise {
+ try {
+ const response = await api.post<{ cleanup?: RetentionCleanupResult }>(
+ `${DM_API}/backups/retention/cleanup`,
+ {},
+ );
+ return response.cleanup ?? {};
+ } catch (error) {
+ if (error instanceof ApiError && error.payload && typeof error.payload === 'object') {
+ const cleanup = (error.payload as { cleanup?: RetentionCleanupResult }).cleanup;
+ if (cleanup) {
+ return { ...cleanup, success: false };
+ }
+ }
+ throw error;
+ }
+}
+
+export interface DeleteBackupResult {
+ job_id?: string;
+ backup_type?: string;
+ deleted_blob_count?: number;
+ job_item_deleted_count?: number;
+ latest_item_state_deleted_count?: number;
+}
+
+export function deleteBackup(backupId: string): Promise {
+ return api
+ .delete<{ cleanup?: DeleteBackupResult }>(`${DM_API}/backups/${encodeURIComponent(backupId)}`, {
+ reason: 'manual',
+ })
+ .then((response) => response.cleanup ?? {});
+}
+
+export type JsonRecord = Record;
+
+export interface JobProgress {
+ current_step?: string;
+ percent_complete?: number | string;
+ completed_steps?: number;
+ total_steps?: number;
+ [key: string]: unknown;
+}
+
+/** A job as `sanitize_data_management_job_for_admin` returns it. */
+export interface DataManagementJob {
+ id: string;
+ operation?: JobOperation | string;
+ backup_type?: string | null;
+ status?: JobStatus | string;
+ created_at?: string | null;
+ updated_at?: string | null;
+ started_at?: string | null;
+ completed_at?: string | null;
+ last_heartbeat_at?: string | null;
+ last_progress_at?: string | null;
+ last_message?: string | null;
+ last_error?: string | null;
+ cancel_requested_at?: string | null;
+ requested_by_email?: string | null;
+ scheduled?: boolean;
+ progress?: JobProgress;
+ warnings?: string[];
+ result?: JsonRecord;
+ migration_state?: JsonRecord | null;
+ backup_state?: JsonRecord | null;
+ restore_state?: JsonRecord | null;
+ can_retry?: boolean;
+ can_cancel?: boolean;
+ submitted_to_executor?: boolean;
+}
+
+export interface JobItem {
+ id?: string;
+ job_id?: string;
+ step_name?: string;
+ status?: string;
+ message?: string | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+ details?: JsonRecord | null;
+}
+
+export interface JobPage {
+ jobs: DataManagementJob[];
+ pagination: HistoryPagination;
+}
+
+export function listJobs(filters: JobFilters, token: string | null, signal?: AbortSignal): Promise {
+ const query = historyQuery(filters, token, { operation: filters.operation ?? '' });
+ return api.get(`${DM_API}/jobs?${query}`, signal).then((response) => ({
+ jobs: Array.isArray(response.jobs) ? response.jobs : [],
+ pagination: response.pagination ?? {},
+ }));
+}
+
+export interface JobDetail {
+ job: DataManagementJob;
+ items: JobItem[];
+}
+
+export function getJobDetail(jobId: string, signal?: AbortSignal): Promise {
+ return api
+ .get<{ job?: DataManagementJob; items?: JobItem[] }>(
+ `${DM_API}/jobs/${encodeURIComponent(jobId)}`,
+ signal,
+ )
+ .then((response) => ({
+ job: response.job ?? { id: jobId },
+ items: Array.isArray(response.items) ? response.items : [],
+ }));
+}
+
+export function getJobProgress(jobId: string, signal?: AbortSignal): Promise {
+ return api
+ .get<{ job?: DataManagementJob }>(`${DM_API}/jobs/${encodeURIComponent(jobId)}/progress`, signal)
+ .then((response) => response.job ?? { id: jobId });
+}
+
+export function retryJob(jobId: string): Promise {
+ return api
+ .post<{ job?: DataManagementJob }>(`${DM_API}/jobs/${encodeURIComponent(jobId)}/retry`, {})
+ .then((response) => response.job ?? { id: jobId });
+}
+
+export function cancelJob(jobId: string, reason: string): Promise {
+ return api
+ .post<{ job?: DataManagementJob }>(`${DM_API}/jobs/${encodeURIComponent(jobId)}/cancel`, { reason })
+ .then((response) => response.job ?? { id: jobId });
+}
+
+/**
+ * Where a migration's manifest downloads from.
+ *
+ * Built from a literal same-origin prefix and an encoded job id, which is what keeps the
+ * value safe to put in an `href`. `failuresOnly` asks for the failed, missing and colliding
+ * entries only.
+ */
+export function migrationManifestUrl(jobId: string, failuresOnly = false): string {
+ const query = failuresOnly ? `?statuses=${encodeURIComponent(MANIFEST_FAILURE_STATUSES)}` : '';
+ return apiUrl(`${DM_API}/jobs/${encodeURIComponent(jobId)}/migration-manifest${query}`);
+}
+
+/** Queue a backup, restore, migration or dry run. Answers 202 with the queued job. */
+export function queueJob(
+ operation: JobOperation,
+ backupType: BackupType | null,
+ options: JsonRecord,
+): Promise {
+ return api
+ .post<{ job?: DataManagementJob }>(`${DM_API}/jobs`, {
+ operation,
+ backup_type: backupType,
+ options,
+ })
+ .then((response) => response.job ?? { id: '' });
+}
+
+/** The workflow step a rejected review or submission points back to, when the server names one. */
+export function readWorkflowStep(error: unknown): string {
+ if (error instanceof ApiError && error.payload && typeof error.payload === 'object') {
+ const step = (error.payload as { workflow_step?: unknown }).workflow_step;
+ return typeof step === 'string' ? step : '';
+ }
+ return '';
+}
+
+// ---------------------------------------------------------------------------------------
+// Restore
+// ---------------------------------------------------------------------------------------
+
+export interface RestorePlan {
+ source_backup_id: string;
+ restore_policy: RestorePolicy;
+ include_cosmos: boolean;
+ include_ai_search: boolean;
+ include_source_blobs: boolean;
+ overwrite_confirmed: boolean;
+ overwrite_confirmation_phrase: string;
+}
+
+export interface ReviewCheck {
+ id?: string;
+ label?: string;
+ status?: string;
+ message?: string;
+ summary?: string;
+ workflow_step?: string;
+ details?: JsonRecord;
+}
+
+export interface RestoreReview {
+ ready?: boolean;
+ blocker_count?: number;
+ warning_count?: number;
+ blockers?: string[];
+ warnings?: string[];
+ checks?: ReviewCheck[];
+ review_fingerprint?: string;
+ authorization_token?: string;
+ authorization_expires_at?: string;
+ summary?: {
+ backup_id?: string;
+ backup_type?: string;
+ completed_at?: string;
+ artifact_count?: number;
+ service_counts?: { cosmos?: number; ai_search?: number; source_blobs?: number };
+ warnings?: number;
+ failed_resource_names?: string[];
+ differential_mode?: string;
+ deletion_policy?: string;
+ };
+ restore_plan?: JsonRecord;
+}
+
+export function reviewRestore(
+ settings: Record,
+ restorePlan: RestorePlan,
+): Promise {
+ return api
+ .post<{ review?: RestoreReview }>(`${DM_API}/restore/review`, { settings, restore_plan: restorePlan })
+ .then((response) => response.review ?? {});
+}
+
+// ---------------------------------------------------------------------------------------
+// Migration
+// ---------------------------------------------------------------------------------------
+
+export interface MigrationSelection {
+ mode: MigrationScopeMode;
+ ids: string[];
+ include_documents: boolean;
+}
+
+export interface MigrationPlan {
+ users: MigrationSelection;
+ groups: MigrationSelection;
+ public_workspaces: MigrationSelection;
+ include_ai_search: boolean;
+ target_ai_search_writes_frozen: boolean;
+ include_source_blobs: boolean;
+ migration_mode: MigrationMode;
+ baseline_job_id: string;
+ mirror_confirmation: string;
+}
+
+export interface CatalogItem {
+ id: string;
+ label?: string;
+ description?: string;
+ document_count?: number;
+}
+
+export interface CatalogPage {
+ type?: string;
+ items: CatalogItem[];
+ total_count: number;
+ page_size?: number;
+ has_more: boolean;
+ continuation_token: string;
+}
+
+export function listMigrationCatalog(
+ targetType: MigrationTargetType,
+ search: string,
+ token: string,
+ signal?: AbortSignal,
+ pageSize = MIGRATION_CATALOG_PAGE_SIZE,
+): Promise {
+ const params = new URLSearchParams({ search, page_size: String(pageSize), continuation_token: token });
+ return api
+ .get>(
+ `${DM_API}/migration/catalog/${encodeURIComponent(targetType)}?${params.toString()}`,
+ signal,
+ )
+ .then((response) => ({
+ type: response.type,
+ items: Array.isArray(response.items) ? response.items : [],
+ total_count: Number(response.total_count ?? 0) || 0,
+ page_size: response.page_size,
+ has_more: response.has_more === true,
+ continuation_token:
+ typeof response.continuation_token === 'string' ? response.continuation_token : '',
+ }));
+}
+
+export interface MigrationScopeSummary {
+ mode?: MigrationScopeMode;
+ count?: number;
+ document_count?: number;
+ include_documents?: boolean;
+ ids?: string[];
+ ids_truncated?: boolean;
+}
+
+export interface MigrationReview {
+ reviewed_at?: string;
+ review_fingerprint?: string;
+ ready?: boolean;
+ blocker_count?: number;
+ warning_count?: number;
+ summary?: Partial> & {
+ include_ai_search?: boolean;
+ include_source_blobs?: boolean;
+ target_ai_search_writes_frozen?: boolean;
+ migration_mode?: MigrationMode;
+ baseline_job_id?: string;
+ };
+ preview?: {
+ captured_at?: string;
+ baseline_job_id?: string;
+ estimated_outcomes?: Partial<
+ Record<
+ | 'create_count'
+ | 'update_count'
+ | 'unchanged_count'
+ | 'delete_count'
+ | 'not_applicable_count'
+ | 'missing_count'
+ | 'conflict_count',
+ number
+ >
+ >;
+ [key: string]: unknown;
+ } | null;
+ checks?: ReviewCheck[];
+ execution?: JsonRecord;
+ authorization_token?: string;
+ authorization_expires_at?: string;
+}
+
+export function reviewMigration(
+ settings: Record,
+ migrationPlan: MigrationPlan,
+): Promise {
+ return api
+ .post<{ review?: MigrationReview }>(`${DM_API}/migration/review`, {
+ settings,
+ migration_plan: migrationPlan,
+ })
+ .then((response) => response.review ?? {});
+}
+
+// ---------------------------------------------------------------------------------------
+// Cosmos editor
+// ---------------------------------------------------------------------------------------
+
+export interface CosmosContainer {
+ id: string;
+ name: string;
+ display_name?: string;
+ category?: string;
+ partition_key_path?: string;
+ partition_key_field?: string;
+ max_page_size?: number;
+ empty_query_limit?: number;
+ editable?: boolean;
+}
+
+export interface CosmosQueryItem {
+ id: string | null;
+ partition_key: unknown;
+ etag?: string | null;
+ timestamp?: number | null;
+ selectable?: boolean;
+ preview?: string;
+}
+
+export interface CosmosQueryResult {
+ container?: CosmosContainer;
+ query?: { mode?: 'empty' | 'custom'; page_size?: number; empty_query_limit_applied?: boolean };
+ items: CosmosQueryItem[];
+ count?: number;
+ continuation_token?: string | null;
+ has_more?: boolean;
+ duration_ms?: number;
+}
+
+export interface CosmosDocumentResult {
+ container?: CosmosContainer;
+ document: JsonRecord;
+ id: string;
+ partition_key: unknown;
+ etag?: string | null;
+ change_summary?: {
+ changed_paths?: string[];
+ changed_count?: number;
+ added_count?: number;
+ removed_count?: number;
+ updated_count?: number;
+ truncated?: boolean;
+ };
+}
+
+export function listCosmosContainers(signal?: AbortSignal): Promise {
+ return api
+ .get<{ containers?: CosmosContainer[] }>(`${DM_API}/cosmos-editor/containers`, signal)
+ .then((response) => (Array.isArray(response.containers) ? response.containers : []));
+}
+
+/** Record that an administrator accepted the editor's danger prompt. The server logs it. */
+export function acknowledgeCosmosDanger(): Promise {
+ return api.post(`${DM_API}/cosmos-editor/danger-acknowledgement`, {}).then(() => undefined);
+}
+
+export function queryCosmos(
+ container: string,
+ query: string,
+ pageSize: number,
+ continuationToken: string | null,
+ signal?: AbortSignal,
+): Promise {
+ return api
+ .post>(
+ `${DM_API}/cosmos-editor/query`,
+ { container, query, page_size: pageSize, continuation_token: continuationToken },
+ signal,
+ )
+ .then((response) => ({ ...response, items: Array.isArray(response.items) ? response.items : [] }));
+}
+
+export function openCosmosDocument(
+ container: string,
+ id: string,
+ partitionKey: unknown,
+ signal?: AbortSignal,
+): Promise {
+ return api.post(
+ `${DM_API}/cosmos-editor/document`,
+ { container, id, partition_key: partitionKey },
+ signal,
+ );
+}
+
+export function saveCosmosDocument(input: {
+ container: string;
+ id: string;
+ partitionKey: unknown;
+ etag: string;
+ document: JsonRecord;
+ confirmationPhrase: string;
+}): Promise {
+ return api.put(`${DM_API}/cosmos-editor/document`, {
+ container: input.container,
+ id: input.id,
+ partition_key: input.partitionKey,
+ etag: input.etag,
+ document: input.document,
+ confirmation_accepted: true,
+ confirmation_phrase: input.confirmationPhrase,
+ });
+}
+
+/** A readable message for any failure, preferring the server's own sanitized text. */
+export function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiError || error instanceof Error) {
+ return error.message || fallback;
+ }
+ return fallback;
+}
diff --git a/application/v2_ui/src/lib/dataManagementFields.ts b/application/v2_ui/src/lib/dataManagementFields.ts
new file mode 100644
index 000000000..0f42f702a
--- /dev/null
+++ b/application/v2_ui/src/lib/dataManagementFields.ts
@@ -0,0 +1,335 @@
+// dataManagementFields.ts
+// How each Backup & Recovery setting is drawn.
+//
+// The controls themselves are the shared Admin Settings ones (`SettingField`,
+// `SecretField`), so these settings read exactly like every other setting on the page. Only
+// the descriptions live here, because the data-management document is saved through its
+// own API rather than the settings schema.
+//
+// `key` is prefixed so the controls' element ids cannot collide with a main setting of the
+// same name; `dmKey` is the key the value is actually stored under.
+
+import type { AdminField } from './adminFields';
+import { DM_NUMBER_BOUNDS, type DmEditableKey } from './dataManagement';
+
+export interface DmFieldDef extends Omit {
+ dmKey: DmEditableKey;
+}
+
+const bounds = (key: DmEditableKey) => {
+ const range = DM_NUMBER_BOUNDS[key];
+ return range ? { min: range.min, max: range.max, step: range.step ?? 1 } : {};
+};
+
+const MANAGED_IDENTITY_OR_CONNECTION_STRING = [
+ { value: 'managed_identity', label: 'Managed identity' },
+ { value: 'connection_string', label: 'Connection string' },
+];
+
+export const DM_FIELDS: Partial> = {
+ // Schedule
+ enabled: {
+ dmKey: 'enabled',
+ type: 'switch',
+ label: 'Scheduled backups',
+ help: 'Take full backups on the cadence below, plus daily partial backups when they are on. Off means a backup runs only when someone queues one.',
+ default: false,
+ },
+ full_backup_frequency: {
+ dmKey: 'full_backup_frequency',
+ type: 'select',
+ label: 'Full backup frequency',
+ help: 'How often a complete snapshot is taken.',
+ default: 'weekly',
+ options: [
+ { value: 'daily', label: 'Daily' },
+ { value: 'weekly', label: 'Weekly' },
+ { value: '14_days', label: 'Every 14 days' },
+ { value: '30_days', label: 'Every 30 days' },
+ ],
+ },
+ partial_backups_enabled: {
+ dmKey: 'partial_backups_enabled',
+ type: 'switch',
+ label: 'Daily partial backups',
+ help: 'Capture changed items every day between full backups. A partial backup restores the latest captured items but does not replay deletions.',
+ default: true,
+ },
+ low_impact_mode: {
+ dmKey: 'low_impact_mode',
+ type: 'switch',
+ label: 'Use low-impact mode for scheduled jobs',
+ default: true,
+ },
+ include_cosmos: {
+ dmKey: 'include_cosmos',
+ type: 'switch',
+ label: 'Cosmos DB records',
+ help: 'Settings, users, workspaces, conversations, documents, agents, actions, prompts and identities. Restore and migration need these.',
+ default: true,
+ },
+ include_ai_search: {
+ dmKey: 'include_ai_search',
+ type: 'switch',
+ label: 'AI Search indexes',
+ help: 'Index schemas and the retrievable documents in the personal, group and public indexes.',
+ default: true,
+ },
+ include_source_blobs: {
+ dmKey: 'include_source_blobs',
+ type: 'switch',
+ label: 'Source document files',
+ help: 'The original files Enhanced Citations keeps.',
+ default: true,
+ },
+
+ // Storage
+ backup_storage_authentication_type: {
+ dmKey: 'backup_storage_authentication_type',
+ type: 'select',
+ label: 'Authentication',
+ help: 'How SimpleChat signs in to the backup storage account.',
+ default: 'managed_identity',
+ options: MANAGED_IDENTITY_OR_CONNECTION_STRING,
+ },
+ backup_storage_blob_endpoint: {
+ dmKey: 'backup_storage_blob_endpoint',
+ type: 'text',
+ input_type: 'url',
+ label: 'Blob endpoint',
+ placeholder: 'https://account.blob.core.windows.net',
+ default: '',
+ },
+ backup_storage_connection_string: {
+ dmKey: 'backup_storage_connection_string',
+ type: 'secret',
+ label: 'Connection string',
+ placeholder: 'Paste the storage connection string',
+ default: '',
+ },
+ backup_storage_container_name: {
+ dmKey: 'backup_storage_container_name',
+ type: 'text',
+ label: 'Container',
+ help: 'Test storage creates it when it does not exist yet.',
+ default: 'simplechat-backups',
+ },
+ backup_storage_path_prefix: {
+ dmKey: 'backup_storage_path_prefix',
+ type: 'text',
+ label: 'Path prefix',
+ help: 'The folder inside the container that backups are written under.',
+ default: 'simplechat-backups',
+ },
+
+ // Encryption
+ encryption_enabled: {
+ dmKey: 'encryption_enabled',
+ type: 'switch',
+ label: 'Encrypt backup files',
+ help: 'Encrypt every backup artifact with the backup key before it is written to storage.',
+ default: true,
+ },
+
+ // Backup performance
+ backup_max_parallel_operations: {
+ dmKey: 'backup_max_parallel_operations',
+ type: 'number',
+ label: 'Concurrent batch staging',
+ help: 'Cosmos batches prepared at once. Higher values finish sooner and put more load on the source database.',
+ default: 4,
+ ...bounds('backup_max_parallel_operations'),
+ },
+ backup_retry_count: {
+ dmKey: 'backup_retry_count',
+ type: 'number',
+ label: 'Retry attempts',
+ help: 'Times a throttled or failed Cosmos batch is retried before the resource is marked failed.',
+ default: 5,
+ ...bounds('backup_retry_count'),
+ },
+ backup_capacity_failure_policy: {
+ dmKey: 'backup_capacity_failure_policy',
+ type: 'select',
+ label: 'If source RU Boost is unavailable',
+ help: 'What a backup does when it cannot raise source capacity.',
+ default: 'continue_without_boost',
+ options: [
+ { value: 'continue_without_boost', label: 'Continue without the boost' },
+ { value: 'fail', label: 'Stop before exporting' },
+ ],
+ },
+ backup_temporary_source_ru_enabled: {
+ dmKey: 'backup_temporary_source_ru_enabled',
+ type: 'switch',
+ label: 'Source RU Boost for backups',
+ help: 'Temporarily raise eligible source Cosmos capacity while a backup runs, then put the original setting back after it completes, fails, is canceled or recovers. This can add Cosmos charges and needs throughput permission on the source account.',
+ default: false,
+ },
+ backup_temporary_source_ru: {
+ dmKey: 'backup_temporary_source_ru',
+ type: 'number',
+ label: 'Source RU Boost target',
+ help: 'RU/s to raise eligible targets to, at most 10,000.',
+ default: 10000,
+ suffix: ' RU/s',
+ ...bounds('backup_temporary_source_ru'),
+ },
+ backup_blob_max_parallel_operations: {
+ dmKey: 'backup_blob_max_parallel_operations',
+ type: 'number',
+ label: 'Concurrent file transfers',
+ help: 'Source files copied at once. Throttling lowers this on its own while it lasts.',
+ default: 4,
+ ...bounds('backup_blob_max_parallel_operations'),
+ },
+ backup_blob_chunk_size_mib: {
+ dmKey: 'backup_blob_chunk_size_mib',
+ type: 'number',
+ label: 'Transfer chunk size (MiB)',
+ help: 'Each file streams through chunks of this size.',
+ default: 8,
+ ...bounds('backup_blob_chunk_size_mib'),
+ },
+ backup_blob_retry_count: {
+ dmKey: 'backup_blob_retry_count',
+ type: 'number',
+ label: 'File retry attempts',
+ help: 'Times a failed file transfer is retried.',
+ default: 5,
+ ...bounds('backup_blob_retry_count'),
+ },
+
+ // Destination (restore and migration)
+ target_cosmos_authentication_type: {
+ dmKey: 'target_cosmos_authentication_type',
+ type: 'select',
+ label: 'Authentication',
+ help: 'Managed identity needs Cosmos DB Data Contributor on the destination account and network access to it.',
+ default: 'managed_identity',
+ options: [
+ { value: 'managed_identity', label: 'Managed identity' },
+ { value: 'key', label: 'Account key' },
+ ],
+ },
+ target_cosmos_endpoint: {
+ dmKey: 'target_cosmos_endpoint',
+ type: 'text',
+ input_type: 'url',
+ label: 'Endpoint',
+ placeholder: 'https://account.documents.azure.com:443/',
+ default: '',
+ },
+ target_cosmos_key: {
+ dmKey: 'target_cosmos_key',
+ type: 'secret',
+ label: 'Account key',
+ default: '',
+ },
+ target_ai_search_authentication_type: {
+ dmKey: 'target_ai_search_authentication_type',
+ type: 'select',
+ label: 'Authentication',
+ default: 'managed_identity',
+ options: [
+ { value: 'managed_identity', label: 'Managed identity' },
+ { value: 'key', label: 'Admin key' },
+ ],
+ },
+ target_ai_search_endpoint: {
+ dmKey: 'target_ai_search_endpoint',
+ type: 'text',
+ input_type: 'url',
+ label: 'Endpoint',
+ placeholder: 'https://search-service.search.windows.net',
+ default: '',
+ },
+ target_ai_search_key: {
+ dmKey: 'target_ai_search_key',
+ type: 'secret',
+ label: 'Admin key',
+ default: '',
+ },
+ target_enhanced_citations_storage_authentication_type: {
+ dmKey: 'target_enhanced_citations_storage_authentication_type',
+ type: 'select',
+ label: 'Authentication',
+ default: 'managed_identity',
+ options: MANAGED_IDENTITY_OR_CONNECTION_STRING,
+ },
+ target_enhanced_citations_storage_blob_endpoint: {
+ dmKey: 'target_enhanced_citations_storage_blob_endpoint',
+ type: 'text',
+ input_type: 'url',
+ label: 'Blob endpoint',
+ placeholder: 'https://account.blob.core.windows.net',
+ default: '',
+ },
+ target_enhanced_citations_storage_connection_string: {
+ dmKey: 'target_enhanced_citations_storage_connection_string',
+ type: 'secret',
+ label: 'Connection string',
+ placeholder: 'Paste the storage connection string',
+ default: '',
+ },
+ target_cosmos_subscription_id: {
+ dmKey: 'target_cosmos_subscription_id',
+ type: 'text',
+ label: 'Subscription ID',
+ help: 'The destination Cosmos account’s subscription. RU Boost needs it to change throughput.',
+ placeholder: '00000000-0000-0000-0000-000000000000',
+ default: '',
+ },
+ target_cosmos_resource_group: {
+ dmKey: 'target_cosmos_resource_group',
+ type: 'text',
+ label: 'Resource group',
+ help: 'The destination Cosmos account’s resource group. RU Boost needs it to change throughput.',
+ default: '',
+ },
+
+ // Migration performance
+ migration_max_parallel_operations: {
+ dmKey: 'migration_max_parallel_operations',
+ type: 'number',
+ label: 'Concurrent operations',
+ default: 8,
+ ...bounds('migration_max_parallel_operations'),
+ },
+ migration_retry_count: {
+ dmKey: 'migration_retry_count',
+ type: 'number',
+ label: 'Retry attempts',
+ default: 5,
+ ...bounds('migration_retry_count'),
+ },
+ migration_skip_recent_within_hours: {
+ dmKey: 'migration_skip_recent_within_hours',
+ type: 'number',
+ label: 'Skip items migrated in the last (hours)',
+ help: 'Items an earlier run copied successfully within this many hours are skipped. 0 skips nothing.',
+ default: 0,
+ ...bounds('migration_skip_recent_within_hours'),
+ },
+ migration_temporary_destination_ru_enabled: {
+ dmKey: 'migration_temporary_destination_ru_enabled',
+ type: 'switch',
+ label: 'Destination RU Boost for this migration',
+ help: 'Temporarily raise eligible destination Cosmos capacity, at most 10,000 RU/s, and put it back after the migration completes or fails. This can add Cosmos charges and needs management-plane permission on the destination account.',
+ default: false,
+ },
+ migration_temporary_destination_ru: {
+ dmKey: 'migration_temporary_destination_ru',
+ type: 'number',
+ label: 'Destination RU Boost target',
+ default: 10000,
+ suffix: ' RU/s',
+ ...bounds('migration_temporary_destination_ru'),
+ },
+};
+
+/** The `AdminField` the shared controls expect, with a collision-free key. */
+export function toAdminField(def: DmFieldDef): AdminField {
+ const { dmKey, ...rest } = def;
+ return { ...rest, key: `data_management_${dmKey}` };
+}
diff --git a/application/v2_ui/src/lib/dataManagementLogic.ts b/application/v2_ui/src/lib/dataManagementLogic.ts
new file mode 100644
index 000000000..9a1db9c22
--- /dev/null
+++ b/application/v2_ui/src/lib/dataManagementLogic.ts
@@ -0,0 +1,1421 @@
+// dataManagementLogic.ts
+// Pure decisions behind Admin Settings > Backup & Recovery.
+//
+// Kept apart from the components because these are the parts worth executing in a test:
+// what a save sends, which edits count as unsaved, whether a review still describes what
+// would run, when a destructive action may proceed, and what a job's telemetry says. Each
+// is invisible in a screenshot and each has a server contract on the other side of it.
+
+import type { SectionStatus } from './adminSections';
+import {
+ COSMOS_EDITOR_MAX_QUERY_LENGTH,
+ DM_DEFAULTS,
+ DM_EDITABLE_KEYS,
+ DM_NUMBER_BOUNDS,
+ HISTORY_MAX_RANGE_DAYS,
+ MIGRATION_MAX_SELECTED_IDS,
+ MIGRATION_TARGET_TYPES,
+ MIRROR_CONFIRMATION_PHRASE,
+ RESTORE_OVERWRITE_PHRASE,
+ RETENTION_MAX_DAYS,
+ RETENTION_UNIT_DAYS,
+ TARGET_COSMOS_DATABASE_NAME,
+ TERMINAL_JOB_STATUSES,
+ ACTIVE_JOB_STATUSES,
+ type BackupGlobalSummary,
+ type CatalogItem,
+ type DataManagementJob,
+ type DmDraft,
+ type DmEditableKey,
+ type DmSettings,
+ type JsonRecord,
+ type MigrationMode,
+ type MigrationPlan,
+ type MigrationReview,
+ type MigrationScopeMode,
+ type MigrationTargetType,
+ type RestorePlan,
+ type RestorePolicy,
+ type RestoreReview,
+ type RetentionUnit,
+ type ReviewCheck,
+} from './dataManagement';
+
+// ---------------------------------------------------------------------------------------
+// Section ids (from admin_settings_nav.py, group "backup-recovery")
+// ---------------------------------------------------------------------------------------
+
+export const DM_SECTION_IDS = {
+ readiness: 'data-management-readiness-section',
+ backup: 'data-management-backup-section',
+ schedule: 'data-management-schedule-section',
+ storage: 'data-management-storage-section',
+ encryption: 'data-management-encryption-section',
+ migration: 'data-management-migration-section',
+ inventory: 'data-management-backup-inventory-section',
+ cosmosEditor: 'data-management-cosmos-editor-section',
+ jobs: 'data-management-jobs-section',
+} as const;
+
+/** Main settings sections the Backup & Recovery cards link to. */
+export const LINKED_SECTION_IDS = {
+ keyVault: 'keyvault-section',
+ enhancedCitations: 'enhanced-citations-section',
+} as const;
+
+/**
+ * Main settings keys the data-management save is validated against.
+ *
+ * The server refuses backup storage that matches the Enhanced Citations account, and reads
+ * those keys from the *saved* settings document. Saving backup settings while one of these
+ * has an unsaved edit would validate against a value that is about to change.
+ */
+export const DM_DEPENDENT_MAIN_KEYS: readonly string[] = [
+ 'enable_enhanced_citations',
+ 'office_docs_storage_account_url',
+ 'office_docs_storage_account_blob_endpoint',
+];
+
+/** Key generation stores the key in Key Vault when these say so, read from saved settings. */
+export const KEY_GENERATION_DEPENDENT_MAIN_KEYS: readonly string[] = [
+ 'enable_key_vault_secret_storage',
+ 'key_vault_name',
+];
+
+// ---------------------------------------------------------------------------------------
+// Values
+// ---------------------------------------------------------------------------------------
+
+export type DmValues = Record;
+
+/** Current value of every editable key: the unsaved edit, else the saved value, else the default. */
+export function readDmValues(settings: DmSettings | null | undefined, draft: DmDraft): DmValues {
+ const values = {} as DmValues;
+ for (const key of DM_EDITABLE_KEYS) {
+ if (Object.prototype.hasOwnProperty.call(draft, key)) {
+ values[key] = draft[key];
+ } else if (settings && settings[key] !== undefined && settings[key] !== null) {
+ values[key] = settings[key];
+ } else {
+ values[key] = DM_DEFAULTS[key];
+ }
+ }
+ return values;
+}
+
+export function asText(value: unknown, fallback = ''): string {
+ if (value === undefined || value === null) return fallback;
+ return typeof value === 'string' ? value : String(value);
+}
+
+export function asFlag(value: unknown): boolean {
+ if (typeof value === 'boolean') return value;
+ if (typeof value === 'string') return ['true', 'on', 'yes', '1'].includes(value.trim().toLowerCase());
+ return Boolean(value);
+}
+
+export function asCount(value: unknown, fallback = 0): number {
+ const parsed = typeof value === 'number' ? value : Number.parseFloat(asText(value));
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+// ---------------------------------------------------------------------------------------
+// Retention
+// ---------------------------------------------------------------------------------------
+
+export function retentionUnitDays(unit: unknown): number {
+ const key = asText(unit) as RetentionUnit;
+ return RETENTION_UNIT_DAYS[key] ?? RETENTION_UNIT_DAYS.days;
+}
+
+/** The largest value the unit allows, so the derived day count stays within the server's cap. */
+export function retentionMaxValue(unit: unknown): number {
+ return Math.max(1, Math.floor(RETENTION_MAX_DAYS / retentionUnitDays(unit)));
+}
+
+/** Days a value and unit come to, clamped exactly as the classic page clamps them. */
+export function computeRetentionDays(value: unknown, unit: unknown): number {
+ const amount = Math.trunc(asCount(value, 30));
+ return Math.max(1, Math.min(RETENTION_MAX_DAYS, amount * retentionUnitDays(unit)));
+}
+
+// ---------------------------------------------------------------------------------------
+// Save payload and unsaved edits
+// ---------------------------------------------------------------------------------------
+
+/**
+ * The object a save sends.
+ *
+ * Mirrors `collectSettings()` on the classic page: every editable key, the derived day
+ * count, the fixed destination database, and only the credential that belongs to the
+ * selected authentication mode. A credential still holding the placeholder is sent as the
+ * placeholder, which the server reads as "keep the stored value".
+ */
+export function buildDmSettingsPayload(values: DmValues): Record {
+ const backupAuth =
+ asText(values.backup_storage_authentication_type, 'managed_identity') || 'managed_identity';
+ const targetStorageAuth =
+ asText(values.target_enhanced_citations_storage_authentication_type, 'managed_identity') ||
+ 'managed_identity';
+ const number = (key: DmEditableKey) => Math.trunc(asCount(values[key], asCount(DM_DEFAULTS[key])));
+
+ return {
+ enabled: asFlag(values.enabled),
+ full_backup_frequency: asText(values.full_backup_frequency) || 'weekly',
+ scheduled_time_utc: asText(values.scheduled_time_utc) || '03:00',
+ retention_value: number('retention_value'),
+ retention_unit: asText(values.retention_unit) || 'days',
+ retention_days: computeRetentionDays(values.retention_value, values.retention_unit),
+ partial_backups_enabled: asFlag(values.partial_backups_enabled),
+ low_impact_mode: asFlag(values.low_impact_mode),
+ include_cosmos: asFlag(values.include_cosmos),
+ include_ai_search: asFlag(values.include_ai_search),
+ include_source_blobs: asFlag(values.include_source_blobs),
+ backup_storage_authentication_type: backupAuth,
+ backup_storage_blob_endpoint:
+ backupAuth === 'managed_identity' ? asText(values.backup_storage_blob_endpoint).trim() : '',
+ backup_storage_container_name:
+ asText(values.backup_storage_container_name).trim() || 'simplechat-backups',
+ backup_storage_connection_string:
+ backupAuth === 'connection_string' ? asText(values.backup_storage_connection_string) : '',
+ backup_storage_path_prefix: asText(values.backup_storage_path_prefix).trim() || 'simplechat-backups',
+ encryption_enabled: asFlag(values.encryption_enabled),
+ backup_max_parallel_operations: number('backup_max_parallel_operations'),
+ backup_retry_count: number('backup_retry_count'),
+ backup_blob_max_parallel_operations: number('backup_blob_max_parallel_operations'),
+ backup_blob_chunk_size_mib: number('backup_blob_chunk_size_mib'),
+ backup_blob_retry_count: number('backup_blob_retry_count'),
+ backup_capacity_failure_policy:
+ asText(values.backup_capacity_failure_policy) || 'continue_without_boost',
+ backup_temporary_source_ru_enabled: asFlag(values.backup_temporary_source_ru_enabled),
+ backup_temporary_source_ru: number('backup_temporary_source_ru'),
+ target_cosmos_authentication_type:
+ asText(values.target_cosmos_authentication_type) || 'managed_identity',
+ target_cosmos_endpoint: asText(values.target_cosmos_endpoint).trim(),
+ target_cosmos_database_name: TARGET_COSMOS_DATABASE_NAME,
+ target_cosmos_key: asText(values.target_cosmos_key),
+ target_cosmos_subscription_id: asText(values.target_cosmos_subscription_id).trim(),
+ target_cosmos_resource_group: asText(values.target_cosmos_resource_group).trim(),
+ target_ai_search_authentication_type:
+ asText(values.target_ai_search_authentication_type) || 'managed_identity',
+ target_ai_search_endpoint: asText(values.target_ai_search_endpoint).trim(),
+ target_ai_search_key: asText(values.target_ai_search_key),
+ target_enhanced_citations_storage_authentication_type: targetStorageAuth,
+ target_enhanced_citations_storage_blob_endpoint:
+ targetStorageAuth === 'managed_identity'
+ ? asText(values.target_enhanced_citations_storage_blob_endpoint).trim()
+ : '',
+ target_enhanced_citations_storage_connection_string:
+ targetStorageAuth === 'connection_string'
+ ? asText(values.target_enhanced_citations_storage_connection_string)
+ : '',
+ migration_max_parallel_operations: number('migration_max_parallel_operations'),
+ migration_retry_count: number('migration_retry_count'),
+ migration_skip_recent_within_hours: number('migration_skip_recent_within_hours'),
+ migration_temporary_destination_ru_enabled: asFlag(values.migration_temporary_destination_ru_enabled),
+ migration_temporary_destination_ru: number('migration_temporary_destination_ru'),
+ };
+}
+
+function comparable(key: DmEditableKey, value: unknown): string {
+ const fallback = DM_DEFAULTS[key];
+ if (typeof fallback === 'boolean') return String(asFlag(value));
+ if (typeof fallback === 'number') return String(asCount(value, fallback));
+ return asText(value).trim();
+}
+
+/**
+ * Keys whose unsaved edit differs from the saved value.
+ *
+ * An edit that was typed and then typed back is not a change, so the Save bar does not
+ * count it. Derived keys never count on their own.
+ */
+export function dmDirtyKeys(settings: DmSettings | null | undefined, draft: DmDraft): DmEditableKey[] {
+ const saved = readDmValues(settings, {});
+ return DM_EDITABLE_KEYS.filter(
+ (key) =>
+ Object.prototype.hasOwnProperty.call(draft, key) &&
+ key !== 'retention_days' &&
+ key !== 'target_cosmos_database_name' &&
+ comparable(key, draft[key]) !== comparable(key, saved[key]),
+ );
+}
+
+const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
+const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function isValidGuid(value: string): boolean {
+ return GUID_PATTERN.test(value.trim());
+}
+
+/**
+ * Problems the browser can see before a save is sent, keyed by settings key.
+ *
+ * The server clamps out-of-range numbers rather than refusing them, which would silently
+ * save a different value than the one on screen. Catching them here keeps what is saved and
+ * what was typed the same.
+ */
+export function validateDmValues(values: DmValues): Partial> {
+ const errors: Partial> = {};
+ if (!TIME_PATTERN.test(asText(values.scheduled_time_utc))) {
+ errors.scheduled_time_utc = 'Enter a time as HH:MM in 24-hour UTC.';
+ }
+ const retention = asCount(values.retention_value, Number.NaN);
+ const retentionMax = retentionMaxValue(values.retention_unit);
+ if (!Number.isInteger(retention) || retention < 1 || retention > retentionMax) {
+ errors.retention_value = `Enter a whole number from 1 to ${retentionMax}.`;
+ }
+ for (const [key, bounds] of Object.entries(DM_NUMBER_BOUNDS) as [
+ DmEditableKey,
+ { min: number; max: number; step?: number },
+ ][]) {
+ const value = asCount(values[key], Number.NaN);
+ if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
+ errors[key] =
+ `Enter a whole number from ${bounds.min.toLocaleString()} to ${bounds.max.toLocaleString()}.`;
+ } else if (bounds.step && (value - bounds.min) % bounds.step !== 0) {
+ errors[key] = `Use steps of ${bounds.step.toLocaleString()}.`;
+ }
+ }
+ if (!asText(values.backup_storage_container_name).trim()) {
+ errors.backup_storage_container_name = 'A container name is required.';
+ }
+ return errors;
+}
+
+// ---------------------------------------------------------------------------------------
+// Configuration state, section status and readiness
+// ---------------------------------------------------------------------------------------
+
+export function isSecretSet(value: unknown): boolean {
+ return asText(value).length > 0;
+}
+
+/** Whether backups have somewhere to go, judged from the current (possibly unsaved) values. */
+export function isBackupStorageConfigured(values: DmValues): boolean {
+ return asText(values.backup_storage_authentication_type) === 'connection_string'
+ ? isSecretSet(values.backup_storage_connection_string)
+ : asText(values.backup_storage_blob_endpoint).trim().length > 0;
+}
+
+export type KeyStorage = 'key_vault' | 'settings' | 'not_configured';
+
+export function readKeyStorage(settings: DmSettings | null | undefined): KeyStorage {
+ const value = asText(settings?.encryption_key_storage);
+ return value === 'key_vault' || value === 'settings' ? value : 'not_configured';
+}
+
+export function keyStorageLabel(storage: KeyStorage): string {
+ if (storage === 'key_vault') return 'Key Vault';
+ if (storage === 'settings') return 'Backup settings document';
+ return 'Not generated';
+}
+
+export function isDestinationConfigured(values: DmValues): boolean {
+ return asText(values.target_cosmos_endpoint).trim().length > 0;
+}
+
+/**
+ * The status chip each Backup & Recovery card shows, where it has one to show.
+ *
+ * Cards that are workbenches rather than configuration -- the inventory, jobs, the editor --
+ * have no configured/unconfigured state, and claiming one would be noise.
+ */
+export function dmSectionStatuses(
+ settings: DmSettings | null | undefined,
+ draft: DmDraft,
+): Partial> {
+ if (!settings) return {};
+ const values = readDmValues(settings, draft);
+ const storageReady = isBackupStorageConfigured(values);
+ const encryptionOn = asFlag(values.encryption_enabled);
+ const keyReady = readKeyStorage(settings) !== 'not_configured';
+ return {
+ [DM_SECTION_IDS.schedule]: asFlag(values.enabled) ? 'ready' : 'off',
+ [DM_SECTION_IDS.storage]: storageReady ? 'ready' : 'incomplete',
+ [DM_SECTION_IDS.encryption]: !encryptionOn ? 'off' : keyReady ? 'ready' : 'incomplete',
+ [DM_SECTION_IDS.backup]: storageReady ? 'none' : 'blocked',
+ };
+}
+
+export type ReadinessState = 'ok' | 'attention' | 'off';
+
+export interface ReadinessItem {
+ id: string;
+ label: string;
+ state: ReadinessState;
+ detail: string;
+ /** The card that resolves this item. */
+ sectionId: string;
+}
+
+/**
+ * The Start Here checklist, built only from what the server reported.
+ *
+ * Nothing here is inferred from a test the browser ran earlier: a storage test proves a
+ * connection at one moment, and showing it as a lasting fact would outlive the proof.
+ */
+export function buildReadinessChecklist(
+ settings: DmSettings | null | undefined,
+ draft: DmDraft,
+ summary: BackupGlobalSummary | null | undefined,
+): ReadinessItem[] {
+ const values = readDmValues(settings, draft);
+ const keyStorage = readKeyStorage(settings);
+ const encryptionOn = asFlag(values.encryption_enabled);
+ const scheduled = asFlag(values.enabled);
+ const latestFull = summary?.latest_full ?? null;
+
+ return [
+ {
+ id: 'storage',
+ label: 'Backup storage',
+ state: isBackupStorageConfigured(values) ? 'ok' : 'attention',
+ detail: isBackupStorageConfigured(values)
+ ? `${asText(values.backup_storage_authentication_type) === 'connection_string' ? 'Connection string' : 'Managed identity'} · container ${asText(values.backup_storage_container_name)}`
+ : 'Choose a dedicated storage account before running a backup.',
+ sectionId: DM_SECTION_IDS.storage,
+ },
+ {
+ id: 'encryption',
+ label: 'Encryption key',
+ state: !encryptionOn
+ ? 'off'
+ : keyStorage === 'not_configured'
+ ? 'attention'
+ : keyStorage === 'settings'
+ ? 'attention'
+ : 'ok',
+ detail: !encryptionOn
+ ? 'Backup artifacts are written unencrypted.'
+ : keyStorage === 'not_configured'
+ ? 'Generate a key before the first encrypted backup.'
+ : keyStorage === 'settings'
+ ? 'Stored in the backup settings document. Key Vault is recommended.'
+ : 'Stored in Key Vault.',
+ sectionId: DM_SECTION_IDS.encryption,
+ },
+ {
+ id: 'schedule',
+ label: 'Schedule',
+ state: scheduled ? 'ok' : 'off',
+ detail: scheduled
+ ? settings?.next_full_backup_run_at
+ ? `Next full backup ${formatDateTime(settings.next_full_backup_run_at)}`
+ : 'Scheduled backups are on.'
+ : 'Backups run only when queued by hand.',
+ sectionId: DM_SECTION_IDS.schedule,
+ },
+ {
+ id: 'latest-full',
+ label: 'Latest full backup',
+ state: latestFull ? 'ok' : 'attention',
+ detail: latestFull
+ ? `Completed ${formatDateTime(latestFull.completed_at || latestFull.created_at)}`
+ : summary
+ ? 'No completed full backup yet. Restore needs one.'
+ : 'Backup history has not loaded yet.',
+ sectionId: DM_SECTION_IDS.inventory,
+ },
+ {
+ id: 'destination',
+ label: 'Restore and migration destination',
+ state: isDestinationConfigured(values) ? 'ok' : 'off',
+ detail: isDestinationConfigured(values)
+ ? `Cosmos DB ${endpointHost(values.target_cosmos_endpoint)}`
+ : 'Needed only to restore or migrate. Set it in Migration.',
+ sectionId: DM_SECTION_IDS.migration,
+ },
+ ];
+}
+
+/** The host part of an endpoint, for summaries that should not repeat a whole URL. */
+export function endpointHost(endpoint: unknown): string {
+ const text = asText(endpoint).trim();
+ if (!text) return '';
+ try {
+ return new URL(text.includes('://') ? text : `https://${text}`).host || text;
+ } catch {
+ return text;
+ }
+}
+
+/** Peak bytes the source-file transfer can buffer: concurrent transfers times chunk size. */
+export function blobBufferEstimateMib(values: DmValues): number {
+ return (
+ Math.max(1, Math.trunc(asCount(values.backup_blob_max_parallel_operations, 4))) *
+ Math.max(1, Math.trunc(asCount(values.backup_blob_chunk_size_mib, 8)))
+ );
+}
+
+// ---------------------------------------------------------------------------------------
+// History lists
+// ---------------------------------------------------------------------------------------
+
+/**
+ * Continuation-token paging with a way back.
+ *
+ * The API pages forward only. Previous works by remembering the token each earlier page was
+ * fetched with, which is also what the classic page does.
+ */
+export interface PagerState {
+ current: string | null;
+ previous: Array;
+ next: string | null;
+}
+
+export const FIRST_PAGE: PagerState = { current: null, previous: [], next: null };
+
+export function pagerAfterLoad(state: PagerState, nextToken: string | null | undefined): PagerState {
+ return { ...state, next: nextToken || null };
+}
+
+export function pagerForward(state: PagerState): PagerState {
+ if (!state.next) return state;
+ return { current: state.next, previous: [...state.previous, state.current], next: null };
+}
+
+export function pagerBack(state: PagerState): PagerState {
+ if (!state.previous.length) return state;
+ const previous = [...state.previous];
+ const current = previous.pop() ?? null;
+ return { current, previous, next: null };
+}
+
+export function pagerPageNumber(state: PagerState): number {
+ return state.previous.length + 1;
+}
+
+/**
+ * Whether a created-date filter can be sent.
+ *
+ * The server needs both dates or neither, the end after the start, and no more than a year
+ * between them. Saying so beside the inputs beats a 400 from the list.
+ */
+export function validateHistoryDateRange(createdFrom: string, createdTo: string): string | null {
+ if (!createdFrom && !createdTo) return null;
+ if (!createdFrom || !createdTo) return 'Choose both a start and an end date.';
+ const start = Date.parse(`${createdFrom}T00:00:00Z`);
+ const end = Date.parse(`${createdTo}T00:00:00Z`);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return 'Enter valid dates.';
+ if (end < start) return 'The end date must be on or after the start date.';
+ // The end date is inclusive, so the window is one day longer than the difference.
+ if ((end - start) / 86_400_000 + 1 > HISTORY_MAX_RANGE_DAYS) {
+ return `Choose a range of ${HISTORY_MAX_RANGE_DAYS} days or less.`;
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------------------
+// Review evidence
+// ---------------------------------------------------------------------------------------
+
+export type CheckTone = 'pass' | 'warning' | 'block';
+
+export interface ReviewCheckView {
+ id: string;
+ label: string;
+ tone: CheckTone;
+ message: string;
+ /** Migration only: the wizard step that fixes this check. */
+ workflowStep?: string;
+}
+
+/**
+ * One shape for both review payloads.
+ *
+ * Restore says `warn` and `message`; migration says `warning` and `summary`. An unknown
+ * status reads as a blocker, which is the conservative choice when a check cannot be
+ * understood.
+ */
+export function normalizeReviewChecks(checks: ReviewCheck[] | undefined | null): ReviewCheckView[] {
+ if (!Array.isArray(checks)) return [];
+ return checks.map((check, index) => {
+ const status = asText(check?.status).toLowerCase();
+ const tone: CheckTone =
+ status === 'pass' || status === 'passed'
+ ? 'pass'
+ : status === 'warn' || status === 'warning'
+ ? 'warning'
+ : 'block';
+ return {
+ id: asText(check?.id) || `check-${index}`,
+ label: asText(check?.label) || asText(check?.id) || 'Review check',
+ tone,
+ message: asText(check?.message) || asText(check?.summary) || 'No details were returned.',
+ workflowStep: asText(check?.workflow_step) || undefined,
+ };
+ });
+}
+
+export interface ReviewHeadline {
+ tone: 'ready' | 'warning' | 'blocked' | 'stale' | 'none';
+ text: string;
+}
+
+export function reviewHeadline(
+ review: { ready?: boolean; blocker_count?: number; warning_count?: number } | null | undefined,
+ stale: boolean,
+): ReviewHeadline {
+ if (!review) return { tone: 'none', text: 'Not reviewed' };
+ if (stale) return { tone: 'stale', text: 'Review is out of date' };
+ if (review.ready) {
+ const warnings = asCount(review.warning_count);
+ return warnings
+ ? { tone: 'warning', text: `Ready with ${warnings} warning${warnings === 1 ? '' : 's'}` }
+ : { tone: 'ready', text: 'Ready' };
+ }
+ const blockers = asCount(review.blocker_count);
+ return { tone: 'blocked', text: `${blockers || 'Has'} blocker${blockers === 1 ? '' : 's'}` };
+}
+
+/** Seconds until a review authorization lapses, or null when the review carries none. */
+export function secondsUntil(expiresAt: string | undefined | null, now: number): number | null {
+ if (!expiresAt) return null;
+ const expiry = Date.parse(expiresAt);
+ return Number.isFinite(expiry) ? Math.max(0, Math.floor((expiry - now) / 1000)) : null;
+}
+
+/** A stable string for a value, with object keys sorted, to compare review inputs. */
+export function stableStringify(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
+ if (value && typeof value === 'object') {
+ const entries = Object.entries(value as Record)
+ .filter(([, entry]) => entry !== undefined)
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`;
+ }
+ return JSON.stringify(value ?? null);
+}
+
+// ---------------------------------------------------------------------------------------
+// Restore
+// ---------------------------------------------------------------------------------------
+
+export interface RestoreDraft {
+ policy: RestorePolicy;
+ includeCosmos: boolean;
+ includeAiSearch: boolean;
+ includeSourceBlobs: boolean;
+ overwritePhrase: string;
+ acknowledged: boolean;
+}
+
+/** Every time the restore dialog opens it starts here, as the classic modal does. */
+export function initialRestoreDraft(): RestoreDraft {
+ return {
+ policy: 'create_only',
+ includeCosmos: true,
+ includeAiSearch: true,
+ includeSourceBlobs: true,
+ overwritePhrase: '',
+ acknowledged: false,
+ };
+}
+
+export function buildRestorePlan(backupId: string, draft: RestoreDraft): RestorePlan {
+ const overwrite = draft.policy === 'overwrite_existing';
+ return {
+ source_backup_id: backupId,
+ restore_policy: draft.policy,
+ include_cosmos: draft.includeCosmos,
+ include_ai_search: draft.includeAiSearch,
+ include_source_blobs: draft.includeSourceBlobs,
+ overwrite_confirmed: overwrite,
+ overwrite_confirmation_phrase: overwrite ? draft.overwritePhrase : '',
+ };
+}
+
+/**
+ * Destination settings a restore review is bound to.
+ *
+ * The first six are what the server's restore fingerprint binds. The storage connection
+ * string is added here: under connection-string sign-in the Blob endpoint is cleared, so it
+ * is the only setting that tells one destination account from another. Reviews always run
+ * after a save, so the value compared is the saved, masked one, and only a new edit to it
+ * makes the review stale.
+ */
+export const RESTORE_FINGERPRINT_SETTINGS: readonly DmEditableKey[] = [
+ 'target_cosmos_authentication_type',
+ 'target_cosmos_endpoint',
+ 'target_ai_search_authentication_type',
+ 'target_ai_search_endpoint',
+ 'target_enhanced_citations_storage_authentication_type',
+ 'target_enhanced_citations_storage_blob_endpoint',
+ 'target_enhanced_citations_storage_connection_string',
+];
+
+export interface RestoreDestinationGap {
+ id: 'cosmos' | 'ai_search' | 'source_blobs';
+ label: string;
+}
+
+/** Destination requirements that apply only to the surfaces selected for this restore. */
+export function restoreDestinationGaps(
+ draft: RestoreDraft,
+ values: DmValues,
+): RestoreDestinationGap[] {
+ const gaps: RestoreDestinationGap[] = [];
+ if (draft.includeCosmos && !asText(values.target_cosmos_endpoint).trim()) {
+ gaps.push({ id: 'cosmos', label: 'Cosmos DB' });
+ }
+ if (draft.includeAiSearch && !asText(values.target_ai_search_endpoint).trim()) {
+ gaps.push({ id: 'ai_search', label: 'AI Search' });
+ }
+ if (draft.includeSourceBlobs) {
+ const auth = asText(
+ values.target_enhanced_citations_storage_authentication_type,
+ 'managed_identity',
+ );
+ const storageReady =
+ auth === 'connection_string'
+ ? isSecretSet(values.target_enhanced_citations_storage_connection_string)
+ : Boolean(asText(values.target_enhanced_citations_storage_blob_endpoint).trim());
+ if (!storageReady) {
+ gaps.push({ id: 'source_blobs', label: 'Enhanced Citation files' });
+ }
+ }
+ return gaps;
+}
+
+/**
+ * What a restore review was run against.
+ *
+ * The typed phrase and the acknowledgement are left out: neither changes what the server
+ * reviewed, so typing them must not throw the review away.
+ */
+export function restoreReviewKey(backupId: string, draft: RestoreDraft, values: DmValues): string {
+ const plan = buildRestorePlan(backupId, draft);
+ return stableStringify({
+ plan: { ...plan, overwrite_confirmation_phrase: undefined },
+ settings: Object.fromEntries(
+ RESTORE_FINGERPRINT_SETTINGS.map((key) => [key, asText(values[key]).trim()]),
+ ),
+ });
+}
+
+export function restoreSurfaceSelected(draft: RestoreDraft): boolean {
+ return draft.includeCosmos || draft.includeAiSearch || draft.includeSourceBlobs;
+}
+
+export function canQueueRestore(input: {
+ review: RestoreReview | null;
+ reviewCurrent: boolean;
+ draft: RestoreDraft;
+ expiresInSeconds: number | null;
+ busy: boolean;
+}): boolean {
+ const { review, reviewCurrent, draft, expiresInSeconds, busy } = input;
+ const phraseOk =
+ draft.policy !== 'overwrite_existing' || draft.overwritePhrase === RESTORE_OVERWRITE_PHRASE;
+ return Boolean(
+ review?.ready === true &&
+ reviewCurrent &&
+ review.authorization_token &&
+ (expiresInSeconds === null || expiresInSeconds > 0) &&
+ draft.acknowledged &&
+ phraseOk &&
+ !busy,
+ );
+}
+
+// ---------------------------------------------------------------------------------------
+// Migration
+// ---------------------------------------------------------------------------------------
+
+/** Step ids match the server's `workflow_step` values, so a check can point back at one. */
+export const MIGRATION_STEPS = ['target', 'scope', 'options', 'review', 'confirm', 'progress'] as const;
+export type MigrationStep = (typeof MIGRATION_STEPS)[number];
+
+export const MIGRATION_STEP_LABELS: Record = {
+ target: 'Destination',
+ scope: 'Scope',
+ options: 'What moves',
+ review: 'Review',
+ confirm: 'Confirm',
+ progress: 'Progress',
+};
+
+export function isMigrationStep(value: string): value is MigrationStep {
+ return (MIGRATION_STEPS as readonly string[]).includes(value);
+}
+
+export const MIGRATION_MODE_LABELS: Record = {
+ new_only: 'Copy missing items only',
+ delta_upsert: 'Catch up changed items',
+ mirror_with_deletions: 'Make destination match source',
+};
+
+export const MIGRATION_TARGET_LABELS: Record = {
+ users: { singular: 'user', plural: 'Users' },
+ groups: { singular: 'group', plural: 'Groups' },
+ public_workspaces: { singular: 'public workspace', plural: 'Public workspaces' },
+};
+
+export interface MigrationScopeState {
+ mode: MigrationScopeMode;
+ /** Chosen records, kept with their labels so the selection reads back across pages. */
+ selected: CatalogItem[];
+ includeDocuments: boolean;
+ /** Server count for All mode; it is display evidence only and is not part of the plan. */
+ serverCount?: number | null;
+ serverCountStatus?: 'idle' | 'loading' | 'ready' | 'error';
+}
+
+export type SubmissionState = 'idle' | 'submitting' | 'accepted' | 'uncertain';
+
+export interface MigrationWizardState {
+ step: MigrationStep;
+ /** Highest step index reached; the rail allows going back to any of these. */
+ reached: number;
+ scopes: Record;
+ includeAiSearch: boolean;
+ searchWritesFrozen: boolean;
+ includeSourceBlobs: boolean;
+ mode: MigrationMode;
+ baselineJobId: string;
+ mirrorPhrase: string;
+ acknowledged: boolean;
+ review: MigrationReview | null;
+ /** `migrationReviewKey` at the moment the review ran. */
+ reviewKey: string | null;
+ reviewError: string | null;
+ submission: SubmissionState;
+ jobId: string | null;
+}
+
+export function initialMigrationState(): MigrationWizardState {
+ const scope = (): MigrationScopeState => ({ mode: 'none', selected: [], includeDocuments: false });
+ return {
+ step: 'target',
+ reached: 0,
+ scopes: { users: scope(), groups: scope(), public_workspaces: scope() },
+ includeAiSearch: true,
+ searchWritesFrozen: false,
+ includeSourceBlobs: false,
+ mode: 'new_only',
+ baselineJobId: '',
+ mirrorPhrase: '',
+ acknowledged: false,
+ review: null,
+ reviewKey: null,
+ reviewError: null,
+ submission: 'idle',
+ jobId: null,
+ };
+}
+
+/** The plan the API expects, built exactly as the classic page builds it. */
+export function buildMigrationPlan(state: MigrationWizardState): MigrationPlan {
+ const selection = (type: MigrationTargetType) => {
+ const scope = state.scopes[type];
+ return {
+ mode: scope.mode,
+ ids: scope.mode === 'selected' ? scope.selected.map((item) => item.id) : [],
+ include_documents: scope.mode !== 'none' && scope.includeDocuments,
+ };
+ };
+ return {
+ users: selection('users'),
+ groups: selection('groups'),
+ public_workspaces: selection('public_workspaces'),
+ include_ai_search: state.includeAiSearch,
+ target_ai_search_writes_frozen: state.includeAiSearch && state.searchWritesFrozen,
+ include_source_blobs: state.includeSourceBlobs,
+ migration_mode: state.mode,
+ baseline_job_id: state.mode === 'new_only' ? '' : state.baselineJobId.trim(),
+ mirror_confirmation: state.mode === 'mirror_with_deletions' ? state.mirrorPhrase : '',
+ };
+}
+
+/** Settings the migration review fingerprint covers (`_migration_review_fingerprint`). */
+export const MIGRATION_FINGERPRINT_SETTINGS: readonly DmEditableKey[] = [
+ 'target_cosmos_authentication_type',
+ 'target_cosmos_endpoint',
+ 'target_cosmos_database_name',
+ 'target_cosmos_key',
+ 'target_cosmos_subscription_id',
+ 'target_cosmos_resource_group',
+ 'target_ai_search_authentication_type',
+ 'target_ai_search_endpoint',
+ 'target_ai_search_key',
+ 'target_enhanced_citations_storage_authentication_type',
+ 'target_enhanced_citations_storage_blob_endpoint',
+ 'target_enhanced_citations_storage_connection_string',
+ 'migration_max_parallel_operations',
+ 'migration_retry_count',
+ 'migration_skip_recent_within_hours',
+ 'migration_temporary_destination_ru_enabled',
+ 'migration_temporary_destination_ru',
+];
+
+/**
+ * What a migration review was run against.
+ *
+ * The mirror phrase is excluded because the server excludes it from its own fingerprint:
+ * it authorizes the run rather than changing it, so typing it on the Confirm step must not
+ * make the review stale.
+ */
+export function migrationReviewKey(state: MigrationWizardState, values: DmValues): string {
+ const payload = buildDmSettingsPayload(values);
+ const plan = buildMigrationPlan(state);
+ return stableStringify({
+ plan: { ...plan, mirror_confirmation: undefined },
+ settings: Object.fromEntries(MIGRATION_FINGERPRINT_SETTINGS.map((key) => [key, payload[key]])),
+ });
+}
+
+export function isMigrationReviewCurrent(state: MigrationWizardState, values: DmValues): boolean {
+ return Boolean(state.review && state.reviewKey && state.reviewKey === migrationReviewKey(state, values));
+}
+
+export function selectedScopeCount(state: MigrationWizardState): number {
+ return MIGRATION_TARGET_TYPES.filter((type) => {
+ const scope = state.scopes[type];
+ return scope.mode === 'all' || (scope.mode === 'selected' && scope.selected.length > 0);
+ }).length;
+}
+
+export function migrationPrincipalCount(state: MigrationWizardState): {
+ total: number;
+ includedKnown: boolean;
+ includedTypeCount: number;
+} {
+ return MIGRATION_TARGET_TYPES.reduce<{
+ total: number;
+ includedKnown: boolean;
+ includedTypeCount: number;
+ }>(
+ (summary, type) => {
+ const scope = state.scopes[type];
+ if (scope.mode === 'none') return summary;
+ summary.includedTypeCount += 1;
+ if (scope.mode === 'selected') {
+ summary.total += scope.selected.length;
+ return summary;
+ }
+ if (scope.serverCountStatus === 'ready' && typeof scope.serverCount === 'number') {
+ summary.total += Math.max(0, scope.serverCount);
+ return summary;
+ }
+ summary.includedKnown = false;
+ return summary;
+ },
+ { total: 0, includedKnown: true, includedTypeCount: 0 },
+ );
+}
+
+/**
+ * Why the wizard cannot move past a step, or null when it can.
+ *
+ * These mirror the classic gates and add the two checks the server would otherwise only
+ * report after a review round trip: a malformed baseline id, and RU Boost without the
+ * subscription and resource group it needs.
+ */
+export function migrationStepIssue(
+ step: MigrationStep,
+ state: MigrationWizardState,
+ values: DmValues,
+): string | null {
+ if (step === 'target') {
+ return isDestinationConfigured(values) ? null : 'Add the destination Cosmos DB endpoint to continue.';
+ }
+ if (step === 'scope') {
+ for (const type of MIGRATION_TARGET_TYPES) {
+ if (state.scopes[type].selected.length > MIGRATION_MAX_SELECTED_IDS) {
+ return `Choose at most ${MIGRATION_MAX_SELECTED_IDS.toLocaleString()} ${MIGRATION_TARGET_LABELS[type].plural.toLowerCase()}, or choose All.`;
+ }
+ }
+ const principalCount = migrationPrincipalCount(state);
+ if (
+ principalCount.includedTypeCount > 0 &&
+ principalCount.includedKnown &&
+ principalCount.total === 0
+ ) {
+ return 'Choose at least one user, group, or public workspace before review.';
+ }
+ return selectedScopeCount(state) > 0 ? null : 'Choose at least one user, group, or public workspace.';
+ }
+ if (step === 'options') {
+ if (state.includeAiSearch && !state.searchWritesFrozen) {
+ return 'Confirm that other writers to the destination AI Search are frozen, or leave AI Search out.';
+ }
+ if (state.mode !== 'new_only' && state.baselineJobId.trim() && !isValidGuid(state.baselineJobId)) {
+ return 'The previous migration job ID must be a job GUID, or left blank.';
+ }
+ if (
+ asFlag(values.migration_temporary_destination_ru_enabled) &&
+ (!asText(values.target_cosmos_subscription_id).trim() ||
+ !asText(values.target_cosmos_resource_group).trim())
+ ) {
+ return 'Destination RU Boost needs the subscription ID and resource group.';
+ }
+ return null;
+ }
+ if (step === 'review') {
+ if (!state.review) return 'Run the preflight review to continue.';
+ if (!isMigrationReviewCurrent(state, values)) return 'Inputs changed after the review. Run it again.';
+ return state.review.ready ? null : 'Resolve the blockers, then run the review again.';
+ }
+ return null;
+}
+
+/** The furthest step the wizard can open now, given every earlier gate. */
+export function furthestOpenStep(state: MigrationWizardState, values: DmValues): number {
+ let index = 0;
+ while (index < MIGRATION_STEPS.length - 1 && !migrationStepIssue(MIGRATION_STEPS[index], state, values)) {
+ index += 1;
+ }
+ return index;
+}
+
+export function canExecuteMigration(state: MigrationWizardState, values: DmValues, now: number): boolean {
+ const review = state.review;
+ const expires = secondsUntil(review?.authorization_expires_at, now);
+ const mirrorOk =
+ state.mode !== 'mirror_with_deletions' || state.mirrorPhrase === MIRROR_CONFIRMATION_PHRASE;
+ return Boolean(
+ review?.ready === true &&
+ review.authorization_token &&
+ isMigrationReviewCurrent(state, values) &&
+ (expires === null || expires > 0) &&
+ state.acknowledged &&
+ mirrorOk &&
+ state.submission === 'idle',
+ );
+}
+
+// ---------------------------------------------------------------------------------------
+// Jobs
+// ---------------------------------------------------------------------------------------
+
+export function isActiveJob(status: unknown): boolean {
+ return ACTIVE_JOB_STATUSES.has(asText(status));
+}
+
+export function isTerminalJob(status: unknown): boolean {
+ return TERMINAL_JOB_STATUSES.has(asText(status));
+}
+
+export function progressPercent(progress: JsonRecord | undefined | null): number {
+ const percent = Number.parseInt(asText(progress?.percent_complete), 10);
+ return Number.isNaN(percent) ? 0 : Math.max(0, Math.min(100, percent));
+}
+
+export type StatusTone = 'ok' | 'warn' | 'danger' | 'active' | 'neutral';
+
+export function jobStatusTone(status: unknown): StatusTone {
+ const value = asText(status);
+ if (value === 'completed') return 'ok';
+ if (value === 'completed_with_warnings') return 'warn';
+ if (value === 'failed' || value === 'canceled') return 'danger';
+ if (value === 'running') return 'active';
+ return 'neutral';
+}
+
+/** `completed_with_warnings` → `Completed with warnings`. */
+export function humanizeToken(value: unknown): string {
+ const text = asText(value).trim();
+ if (!text) return '';
+ if (text in MIGRATION_MODE_LABELS) return MIGRATION_MODE_LABELS[text as MigrationMode];
+ const words = text.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+export function formatBackupType(value: unknown): string {
+ const text = asText(value);
+ if (text === 'full') return 'Full';
+ if (text === 'partial') return 'Partial';
+ return humanizeToken(text || 'backup');
+}
+
+export function formatOperation(operation: unknown, backupType?: unknown): string {
+ const op = asText(operation);
+ if (op === 'backup') return `${formatBackupType(backupType || 'manual')} backup`;
+ if (op === 'restore') return 'Restore';
+ if (op === 'migration') return 'Migration';
+ if (op === 'dry_run') return 'Dry run';
+ return humanizeToken(op || 'job');
+}
+
+/** The retry label the classic page uses, which says what retrying will actually do. */
+export function retryLabel(job: DataManagementJob): string {
+ if (asText(job.status) === 'running') return 'Resume';
+ return asText(job.operation) === 'backup' ? 'Retry failures' : 'Retry';
+}
+
+export function formatNumber(value: unknown): string {
+ const number = asCount(value, 0);
+ return Number.isFinite(number) ? number.toLocaleString() : '0';
+}
+
+export function formatBytes(value: unknown): string {
+ let size = asCount(value, 0);
+ if (!Number.isFinite(size) || size <= 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let unit = 0;
+ while (size >= 1024 && unit < units.length - 1) {
+ size /= 1024;
+ unit += 1;
+ }
+ return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
+}
+
+export function formatDateTime(value: unknown): string {
+ const text = asText(value);
+ if (!text) return '';
+ const parsed = new Date(text);
+ return Number.isNaN(parsed.getTime()) ? text : parsed.toLocaleString();
+}
+
+export function timestampAgeSeconds(value: unknown, now: number): number {
+ const timestamp = Date.parse(asText(value));
+ return Number.isFinite(timestamp)
+ ? Math.max(0, Math.floor((now - timestamp) / 1000))
+ : Number.POSITIVE_INFINITY;
+}
+
+export function formatAge(value: unknown, now: number): string {
+ const age = timestampAgeSeconds(value, now);
+ if (!Number.isFinite(age)) return '';
+ if (age < 60) return `${age}s ago`;
+ if (age < 3600) return `${Math.floor(age / 60)}m ago`;
+ return `${Math.floor(age / 3600)}h ago`;
+}
+
+export interface Metric {
+ label: string;
+ value: string;
+}
+
+function record(value: unknown): JsonRecord {
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {};
+}
+
+/** Live migration telemetry, as the classic progress view reports it. */
+export function migrationLiveMetrics(job: DataManagementJob, now: number): Metric[] {
+ const state = record(job.migration_state);
+ if (!Object.keys(state).length) return [];
+ const totals = record(state.totals);
+ const resources = Object.values(record(state.resources)).map(record);
+ const active = resources.find((resource) => resource.status === 'in_progress') ?? {};
+ const activeMetrics = Object.keys(record(active.progress)).length
+ ? record(active.progress)
+ : record(active.result);
+ const capacity = record(state.capacity);
+ const metrics: Metric[] = [
+ { label: 'Processed', value: formatNumber(totals.processed_count) },
+ { label: 'Transferred', value: formatBytes(totals.bytes) },
+ {
+ label: 'Copied / skipped',
+ value: `${formatNumber(totals.copied_count)} / ${formatNumber(totals.skipped_count)}`,
+ },
+ { label: 'Failures', value: formatNumber(totals.failed_count) },
+ { label: 'Collisions', value: formatNumber(totals.collision_count) },
+ ];
+ if (activeMetrics.observed_bytes !== undefined) {
+ metrics.push({ label: 'Observed transferred', value: formatBytes(activeMetrics.observed_bytes) });
+ }
+ if (activeMetrics.items_per_second !== undefined) {
+ metrics.push({ label: 'Items per second', value: formatNumber(activeMetrics.items_per_second) });
+ }
+ const rate = activeMetrics.observed_bytes_per_second ?? activeMetrics.bytes_per_second;
+ if (rate !== undefined) metrics.push({ label: 'Transfer rate', value: `${formatBytes(rate)}/s` });
+ if (activeMetrics.request_units_per_second !== undefined) {
+ metrics.push({ label: 'RU per second', value: formatNumber(activeMetrics.request_units_per_second) });
+ }
+ if (capacity.status)
+ metrics.push({ label: 'Destination capacity', value: humanizeToken(capacity.status) });
+ const heartbeat = formatAge(job.last_heartbeat_at, now);
+ const progressAt = job.last_progress_at || asText(state.last_progress_at);
+ const progressAge = formatAge(progressAt, now);
+ if (heartbeat) metrics.push({ label: 'Last heartbeat', value: heartbeat });
+ if (progressAge) metrics.push({ label: 'Last progress', value: progressAge });
+ if (heartbeat && !isTerminalJob(job.status)) {
+ metrics.push({
+ label: 'Liveness',
+ value:
+ timestampAgeSeconds(progressAt, now) <= 10
+ ? 'Running, progress active'
+ : 'Running, alive with no recent progress',
+ });
+ }
+ return metrics;
+}
+
+/** Live backup telemetry, as the classic progress view reports it. */
+export function backupLiveMetrics(job: DataManagementJob): Metric[] {
+ const state = record(job.backup_state);
+ if (!Object.keys(state).length) return [];
+ const totals = record(state.totals);
+ const telemetry = record(state.telemetry);
+ const capacity = record(state.source_capacity);
+ const metrics: Metric[] = [];
+ if (!isTerminalJob(job.status)) {
+ metrics.push({ label: 'Current container', value: asText(telemetry.current_container) || 'Waiting' });
+ }
+ metrics.push(
+ {
+ label: 'Checkpoint position',
+ value: formatNumber(telemetry.checkpoint_position ?? totals.checkpoint_count),
+ },
+ { label: 'Processed', value: formatNumber(telemetry.records_processed ?? totals.processed_count) },
+ { label: 'Transferred', value: formatBytes(telemetry.bytes ?? totals.bytes) },
+ { label: 'Request units', value: formatNumber(telemetry.request_units ?? totals.request_units) },
+ {
+ label: 'Retries / throttles',
+ value: `${formatNumber(telemetry.retries ?? totals.retry_attempt_count)} / ${formatNumber(telemetry.throttles ?? totals.throttle_count)}`,
+ },
+ {
+ label: 'Skipped / failed',
+ value: `${formatNumber(totals.skipped_count)} / ${formatNumber(totals.failed_count)}`,
+ },
+ );
+ const elapsed = telemetry.elapsed_seconds ?? totals.elapsed_seconds;
+ if (elapsed !== undefined) metrics.push({ label: 'Elapsed', value: `${formatNumber(elapsed)}s` });
+ if (telemetry.records_per_second !== undefined) {
+ metrics.push({ label: 'Records per second', value: formatNumber(telemetry.records_per_second) });
+ }
+ if (telemetry.request_units_per_second !== undefined) {
+ metrics.push({ label: 'RU per second', value: formatNumber(telemetry.request_units_per_second) });
+ }
+ if (capacity.status) metrics.push({ label: 'Source capacity', value: humanizeToken(capacity.status) });
+ if (capacity.restore_pending) metrics.push({ label: 'Capacity restore', value: 'Pending recovery' });
+ return metrics;
+}
+
+/** Live restore telemetry, as the classic progress view reports it. */
+export function restoreLiveMetrics(job: DataManagementJob, now: number): Metric[] {
+ const state = record(job.restore_state);
+ if (!Object.keys(state).length) return [];
+ const totals = record(state.totals);
+ const resources = Object.values(record(state.resources)).map(record);
+ const active = record((resources.find((resource) => resource.status === 'in_progress') ?? {}).progress);
+ const metrics: Metric[] = [
+ { label: 'Processed', value: formatNumber(totals.processed_count) },
+ { label: 'Copied', value: formatNumber(totals.copied_count) },
+ {
+ label: 'Created / updated',
+ value: `${formatNumber(totals.created_count)} / ${formatNumber(totals.updated_count)}`,
+ },
+ {
+ label: 'Skipped / failed',
+ value: `${formatNumber(totals.skipped_count)} / ${formatNumber(totals.failed_count)}`,
+ },
+ { label: 'Collisions', value: formatNumber(totals.collision_count) },
+ { label: 'Transferred', value: formatBytes(totals.bytes) },
+ ];
+ const target = asText(active.container_name) || asText(active.index_name);
+ if (target) metrics.push({ label: 'Current target', value: target });
+ const progressAge = formatAge(job.last_progress_at || asText(state.last_progress_at), now);
+ if (progressAge) metrics.push({ label: 'Last progress', value: progressAge });
+ return metrics;
+}
+
+export function liveMetrics(job: DataManagementJob, now: number): Metric[] {
+ const operation = asText(job.operation);
+ if (operation === 'migration') return migrationLiveMetrics(job, now);
+ if (operation === 'backup') return backupLiveMetrics(job);
+ if (operation === 'restore') return restoreLiveMetrics(job, now);
+ return [];
+}
+
+/** Artifacts a job recorded: on its result, or failing that on an export timeline item. */
+export function jobArtifacts(
+ job: DataManagementJob,
+ items: Array<{ details?: JsonRecord | null }>,
+): JsonRecord[] {
+ const fromResult = record(job.result).artifacts;
+ if (Array.isArray(fromResult) && fromResult.length) return fromResult.map(record);
+ const exportItem = items.find((item) => Array.isArray(record(item.details).artifacts));
+ const fromItem = exportItem ? record(exportItem.details).artifacts : [];
+ return Array.isArray(fromItem) ? fromItem.map(record) : [];
+}
+
+/** Job-level warnings plus each artifact's own, in that order. */
+export function jobWarnings(job: DataManagementJob, artifacts: JsonRecord[]): string[] {
+ const warnings = (Array.isArray(job.warnings) ? job.warnings : [])
+ .map((warning) => asText(warning))
+ .filter(Boolean);
+ for (const artifact of artifacts) {
+ if (artifact.warning) {
+ warnings.push(
+ `${asText(artifact.name) || asText(artifact.type) || 'Artifact'}: ${asText(artifact.warning)}`,
+ );
+ }
+ }
+ return warnings;
+}
+
+function isPresent(value: unknown): boolean {
+ if (value === null || value === undefined) return false;
+ if (typeof value === 'string') return value.trim() !== '';
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value === 'object') return Object.keys(value as object).length > 0;
+ return true;
+}
+
+export function formatDetailValue(value: unknown): string {
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
+ if (typeof value === 'number') return formatNumber(value);
+ if (value === null || value === undefined || value === '') return 'N/A';
+ const text = String(value);
+ return text.length > 96 ? `${text.slice(0, 60)}…${text.slice(-24)}` : text;
+}
+
+/** Flatten a structured details object into labelled chips, at most `limit` of them. */
+export function flattenDetails(value: unknown, limit = 12, prefix = ''): Metric[] {
+ const entries: Metric[] = [];
+ for (const [key, detail] of Object.entries(record(value))) {
+ if (!isPresent(detail)) continue;
+ const label = prefix ? `${prefix} ${humanizeToken(key).toLowerCase()}` : humanizeToken(key);
+ if (key === 'artifacts' && Array.isArray(detail)) {
+ entries.push({ label, value: `${formatNumber(detail.length)} recorded` });
+ } else if (Array.isArray(detail)) {
+ const primitive = detail.every(
+ (item) => item === null || ['string', 'number', 'boolean'].includes(typeof item),
+ );
+ entries.push({
+ label,
+ value: primitive
+ ? detail.slice(0, 4).map(formatDetailValue).join(', ')
+ : `${formatNumber(detail.length)} items`,
+ });
+ } else if (typeof detail === 'object') {
+ entries.push(...flattenDetails(detail, limit, label));
+ } else {
+ entries.push({ label, value: formatDetailValue(detail) });
+ }
+ if (entries.length >= limit) break;
+ }
+ return entries.slice(0, limit);
+}
+
+// ---------------------------------------------------------------------------------------
+// Cosmos editor
+// ---------------------------------------------------------------------------------------
+
+/**
+ * Why a query cannot be run, or null when it can.
+ *
+ * The server enforces the same rules; checking here turns a refused request into guidance
+ * beside the box.
+ */
+export function validateCosmosQuery(query: string): string | null {
+ const text = query.trim();
+ if (!text) return null;
+ if (text.length > COSMOS_EDITOR_MAX_QUERY_LENGTH) {
+ return `Queries are limited to ${COSMOS_EDITOR_MAX_QUERY_LENGTH.toLocaleString()} characters.`;
+ }
+ if (text.includes(';')) return 'Run one SELECT query at a time, without semicolons.';
+ if (!/^SELECT\b/i.test(text)) return 'Queries must start with SELECT.';
+ return null;
+}
+
+/** Read a dotted path such as `/user_id` or `/a/b` out of a document. */
+export function readPartitionValue(document: unknown, partitionKeyPath: string | undefined): unknown {
+ const segments = asText(partitionKeyPath).split('/').filter(Boolean);
+ let node: unknown = document;
+ for (const segment of segments) {
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return undefined;
+ node = (node as JsonRecord)[segment];
+ }
+ return node;
+}
+
+export type CosmosEditCheck = { ok: true; document: JsonRecord } | { ok: false; error: string };
+
+/**
+ * Whether edited JSON can be saved over the opened document.
+ *
+ * The id and partition key cannot change, because the save replaces the document at the
+ * address it was opened from; the server refuses both, and saying so before the typed
+ * confirmation saves an administrator a round trip.
+ */
+export function checkCosmosEdit(
+ text: string,
+ original: { id: string; partitionKey: unknown; partitionKeyPath?: string },
+): CosmosEditCheck {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (error) {
+ return {
+ ok: false,
+ error: `This is not valid JSON: ${error instanceof Error ? error.message : 'parse error'}.`,
+ };
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return { ok: false, error: 'A Cosmos DB document must be a JSON object.' };
+ }
+ const document = parsed as JsonRecord;
+ if (asText(document.id) !== original.id) {
+ return { ok: false, error: 'The id cannot be changed here. Restore it to save.' };
+ }
+ const partition = readPartitionValue(document, original.partitionKeyPath);
+ if (stableStringify(partition) !== stableStringify(original.partitionKey)) {
+ return { ok: false, error: 'The partition key value cannot be changed here. Restore it to save.' };
+ }
+ return { ok: true, document };
+}
+
+/**
+ * Pretty-print a document for editing.
+ *
+ * System fields (`_etag`, `_ts` and the rest) stay in view, as on the classic page. The
+ * server ignores them when comparing and strips them before writing.
+ */
+export function formatCosmosDocument(document: JsonRecord): string {
+ return JSON.stringify(document, null, 2);
+}
+
+export interface CosmosChangeSummary {
+ changedPaths: string[];
+ addedCount: number;
+ removedCount: number;
+ updatedCount: number;
+}
+
+/**
+ * What an edit changes, shown before the typed confirmation.
+ *
+ * The same walk the server records after saving: system fields are skipped, and a key
+ * added or removed counts once rather than once per nested value.
+ */
+export function summarizeCosmosChanges(original: unknown, updated: unknown): CosmosChangeSummary {
+ const summary: CosmosChangeSummary = {
+ changedPaths: [],
+ addedCount: 0,
+ removedCount: 0,
+ updatedCount: 0,
+ };
+ const isObject = (value: unknown): value is JsonRecord =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+ const compare = (before: unknown, after: unknown, path: string) => {
+ if (isObject(before) && isObject(after)) {
+ const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
+ for (const key of keys) {
+ if (key.startsWith('_')) continue;
+ const childPath = path ? `${path}.${key}` : key;
+ if (!Object.prototype.hasOwnProperty.call(before, key)) {
+ summary.addedCount += 1;
+ summary.changedPaths.push(childPath);
+ } else if (!Object.prototype.hasOwnProperty.call(after, key)) {
+ summary.removedCount += 1;
+ summary.changedPaths.push(childPath);
+ } else {
+ compare(before[key], after[key], childPath);
+ }
+ }
+ return;
+ }
+ if (stableStringify(before) !== stableStringify(after)) {
+ summary.updatedCount += 1;
+ summary.changedPaths.push(path);
+ }
+ };
+
+ compare(original ?? {}, updated ?? {}, '');
+ return summary;
+}
diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx
index b8b6e1915..2d40870e6 100644
--- a/application/v2_ui/src/pages/AdminSettingsPage.tsx
+++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx
@@ -33,6 +33,7 @@ import { useBootstrapStore } from '../stores/bootstrapStore';
import { useUserSettingsStore } from '../stores/userSettingsStore';
import { PageHeader } from '../components/layout/PageHeader';
import { GlassButton, GlassPanel, Skeleton, Toggle } from '../components/ui/primitives';
+import { ConfirmDialog } from '../components/ui/ConfirmDialog';
import { AdminModal } from '../components/admin/AdminModal';
import { AdminMarkdown } from '../components/admin/AdminMarkdown';
import { AppRoleRoster } from '../components/admin/AppRoleRoster';
@@ -73,6 +74,15 @@ import { ModelSelectionPicker } from '../components/admin/ModelSelectionPicker';
import { OrchestrationCard } from '../components/admin/OrchestrationCard';
import { OrchestrationPlannerModelPicker } from '../components/admin/OrchestrationPlannerModelPicker';
import { PromotedAgentsEditor } from '../components/admin/PromotedAgentsEditor';
+import { DataManagementBackupInventory } from '../components/admin/dataManagement/DataManagementBackupInventory';
+import { DataManagementBackupRuns } from '../components/admin/dataManagement/DataManagementBackupRuns';
+import { DataManagementCosmosEditor } from '../components/admin/dataManagement/DataManagementCosmosEditor';
+import { DataManagementEncryption } from '../components/admin/dataManagement/DataManagementEncryption';
+import { DataManagementJobs } from '../components/admin/dataManagement/DataManagementJobs';
+import { DataManagementMigration } from '../components/admin/dataManagement/DataManagementMigration';
+import { DataManagementReadiness } from '../components/admin/dataManagement/DataManagementReadiness';
+import { DataManagementSchedule } from '../components/admin/dataManagement/DataManagementSchedule';
+import { DataManagementStorage } from '../components/admin/dataManagement/DataManagementStorage';
import { AgentTemplateApprovalsLink } from '../components/admin/AgentTemplateApprovalsLink';
import { GlobalActionsManager } from '../components/admin/GlobalActionsManager';
import { GlobalAgentsManager } from '../components/admin/GlobalAgentsManager';
@@ -141,6 +151,7 @@ import {
hasUnsavedThroughputEdits,
validateCosmosThroughputPolicy,
} from '../lib/cosmosThroughput';
+import { dmDirtyKeys } from '../lib/dataManagementLogic';
import {
CONTENT_UNDERSTANDING_SUPPORTED_FLAG,
resolveEnhancedExtractionEngine,
@@ -148,6 +159,12 @@ import {
import { hasUnsavedDiscoveryEdits } from '../lib/modelSelection';
import { PLANNER_MODEL_KEYS } from '../lib/orchestrationPlannerModel';
import { modelConnectionsChanged, requestConnectionFocus } from '../stores/modelConnectionsStore';
+import {
+ selectCosmosEditorDirty,
+ useDataManagementStore,
+ useDmDirtyCount,
+ useDmSectionStatuses,
+} from '../stores/dataManagementStore';
import { useScaleStatusStore } from '../stores/scaleStatusStore';
import type { CatalogConnectionTarget } from '../lib/modelCatalog';
import type { AdminNavGroup, Json } from '../lib/types';
@@ -318,6 +335,19 @@ export function AdminSettingsPage({ focusSection }: { focusSection?: string } =
/** The pane the cards scroll inside; the page index watches it to mark the current section. */
const scrollRef = useRef(null);
+ // Backup & Recovery settings are a separate document with their own API. Their unsaved
+ // edits count in the Save bar and are saved after the main settings, so the page holds
+ // one save model for both. See `dataManagementStore.ts`.
+ const dmDirtyCount = useDmDirtyCount();
+ const dmSaving = useDataManagementStore((state) => state.saving);
+ const dmPendingRequests = useDataManagementStore((state) => state.pendingRequests);
+ const dmCosmosDirty = useDataManagementStore(selectCosmosEditorDirty);
+ const dmSectionStatuses = useDmSectionStatuses();
+
+ // Everything Backup & Recovery holds lives for as long as this page does, as the main
+ // draft does: leaving the page discards it, and the navigation guard asks first.
+ useEffect(() => () => useDataManagementStore.getState().reset(), []);
+
useEffect(() => {
if (!isAdmin) {
setLoading(false);
@@ -588,25 +618,28 @@ export function AdminSettingsPage({ focusSection }: { focusSection?: string } =
* Each section's status, read from the whole section rather than a search's matches.
*
* The card chip and the page index both show this, so they cannot disagree, and
- * narrowing the page with a search does not change what a section reports.
+ * narrowing the page with a search does not change what a section reports. Backup &
+ * Recovery sections are components over a separate settings document, so their status
+ * comes from that document rather than from fields.
*/
const statusBySection = useMemo(() => {
const statuses = new Map();
for (const section of sections) {
statuses.set(
section.sectionId,
- computeSectionStatus(
- section.allFields,
- settings,
- draft,
- sectionStatus[section.sectionId],
- fieldsByKey,
- runtimeFlags,
- ),
+ dmSectionStatuses[section.sectionId] ??
+ computeSectionStatus(
+ section.allFields,
+ settings,
+ draft,
+ sectionStatus[section.sectionId],
+ fieldsByKey,
+ runtimeFlags,
+ ),
);
}
return statuses;
- }, [sections, settings, draft, sectionStatus, fieldsByKey, runtimeFlags]);
+ }, [sections, settings, draft, sectionStatus, fieldsByKey, runtimeFlags, dmSectionStatuses]);
/**
* Which sections rely on which, from their `requires` declarations.
@@ -777,83 +810,168 @@ export function AdminSettingsPage({ focusSection }: { focusSection?: string } =
setDraft({});
setFieldErrors({});
setFieldWarnings({});
+ useDataManagementStore.getState().discard();
}, []);
- // Opening a global agent or action editor replaces this page, and its unsaved settings
- // with it. Ask first, as the editors themselves do when they are left with changes.
- const unsavedSettings = dirtyKeys.length > 0 || saving;
- const leaveForEditor = useBlocker(
- ({ nextLocation }) => unsavedSettings && GLOBAL_EDITOR_PATH.test(nextLocation.pathname),
- );
- useEffect(() => {
- // A save that finishes while the question is open leaves nothing to lose.
- if (leaveForEditor.state === 'blocked' && !unsavedSettings) {
- leaveForEditor.proceed();
- }
- }, [leaveForEditor, unsavedSettings]);
-
+ /**
+ * Save everything the Save bar counts: main settings first, then Backup & Recovery.
+ *
+ * The order matters. Backup storage is validated against the Enhanced Citations storage
+ * in the saved main settings, so the main save has to land first. A failed main save
+ * stops there, leaving both drafts on screen; a failed Backup & Recovery save after a
+ * successful main save is reported as the partial save it is, with only that draft left.
+ * Resolves true when nothing is left unsaved.
+ */
const save = useCallback(async () => {
- if (!Object.keys(draft).length) {
- return;
+ const dmState = useDataManagementStore.getState();
+ const mainDirty = Object.keys(draft).length > 0;
+ const dmDirty = dmState.settings ? dmDirtyKeys(dmState.settings, dmState.draft).length > 0 : false;
+ if (!mainDirty && !dmDirty) {
+ return true;
}
setSaving(true);
setError(null);
setFieldErrors({});
+ let mainSaved = 0;
+ let warningCount = 0;
try {
- const response = await api.patch(
- '/api/v2/admin/settings',
- { settings: draft },
- );
+ if (mainDirty) {
+ try {
+ const response = await api.patch(
+ '/api/v2/admin/settings',
+ { settings: draft },
+ );
- setData((current) =>
- current
- ? { ...current, settings: { ...current.settings, ...response.settings } }
- : current,
- );
- setFieldWarnings(response.warnings ?? {});
- setDraft({});
- if (response.updated_keys.includes('enable_content_screening')) {
- setScreeningConfigurationVersion((version) => version + 1);
+ setData((current) =>
+ current
+ ? { ...current, settings: { ...current.settings, ...response.settings } }
+ : current,
+ );
+ setFieldWarnings(response.warnings ?? {});
+ setDraft({});
+ // Published now rather than after the next render: an action waiting on
+ // "Save all" checks these keys again as soon as this save resolves.
+ useDataManagementStore.getState().setMainDirtyKeys([]);
+ if (response.updated_keys.includes('enable_content_screening')) {
+ setScreeningConfigurationVersion((version) => version + 1);
+ }
+ void refreshBootstrap();
+
+ // Enabling connections carries the classic chat endpoint into the connection
+ // list server-side. That write happens here rather than in the connections
+ // section, so nothing else would tell it, or the default model picker, that
+ // the list they are showing is no longer what is stored.
+ if (
+ response.updated_keys.includes('model_endpoints') ||
+ response.updated_keys.includes('enable_multi_model_endpoints')
+ ) {
+ modelConnectionsChanged();
+ }
+
+ mainSaved = response.updated_keys.length;
+ warningCount = Object.keys(response.warnings ?? {}).length;
+ } catch (saveError) {
+ const errors =
+ saveError instanceof ApiError ? extractFieldErrors(saveError.payload) : {};
+ if (Object.keys(errors).length) {
+ // Keep the draft so the rejected values stay on screen next to their errors.
+ setFieldErrors(errors);
+ toast.error('Some settings could not be saved.');
+ } else {
+ setError(
+ saveError instanceof Error ? saveError.message : 'Failed to save settings.',
+ );
+ }
+ return false;
+ }
}
- void refreshBootstrap();
- // Enabling connections carries the classic chat endpoint into the connection
- // list server-side. That write happens here rather than in the connections
- // section, so nothing else would tell it, or the default model picker, that
- // the list they are showing is no longer what is stored.
- if (
- response.updated_keys.includes('model_endpoints') ||
- response.updated_keys.includes('enable_multi_model_endpoints')
- ) {
- modelConnectionsChanged();
+ if (dmDirty) {
+ const outcome = await useDataManagementStore.getState().save();
+ if (!outcome.ok) {
+ const reason = outcome.error ?? 'Backup & Recovery settings could not be saved.';
+ toast.error(
+ mainDirty
+ ? `Saved ${mainSaved} setting${mainSaved === 1 ? '' : 's'}, but Backup & Recovery settings were not saved. ${reason}`
+ : reason,
+ );
+ return false;
+ }
}
- const warningCount = Object.keys(response.warnings ?? {}).length;
+ const mainText = `${mainSaved} setting${mainSaved === 1 ? '' : 's'}`;
toast.success(
warningCount
? `Saved with ${warningCount} warning${warningCount === 1 ? '' : 's'}.`
- : `Saved ${response.updated_keys.length} setting${
- response.updated_keys.length === 1 ? '' : 's'
- }.`,
+ : dmDirty && mainDirty
+ ? `Saved ${mainText} and Backup & Recovery settings.`
+ : dmDirty
+ ? 'Saved Backup & Recovery settings.'
+ : `Saved ${mainText}.`,
);
- } catch (saveError) {
- const errors =
- saveError instanceof ApiError ? extractFieldErrors(saveError.payload) : {};
- if (Object.keys(errors).length) {
- // Keep the draft so the rejected values stay on screen next to their errors.
- setFieldErrors(errors);
- toast.error('Some settings could not be saved.');
- } else {
- setError(
- saveError instanceof Error ? saveError.message : 'Failed to save settings.',
- );
- }
+ return true;
} finally {
setSaving(false);
}
}, [draft, refreshBootstrap]);
+ // The Backup & Recovery cards ask the page to save everything before an action that has
+ // to run against saved settings, and need to know which main settings are unsaved.
+ useEffect(() => {
+ useDataManagementStore.getState().registerSaveAll(save);
+ }, [save]);
+ useEffect(() => {
+ useDataManagementStore.getState().setMainDirtyKeys(dirtyKeys);
+ }, [dirtyKeys]);
+
+ /**
+ * Ask before leaving the page with work that would be lost.
+ *
+ * The Save bar's unload prompt only covers closing the tab; moving to another page of the
+ * app is a client-side navigation it never sees, and used to drop every unsaved edit
+ * silently. A backup, restore or migration request still in flight is included: leaving
+ * does not stop it, and the administrator should know to check Job history.
+ *
+ * A router honours one blocker at a time (React Router warns and uses the last one
+ * registered), so this one guard covers every way out. Opening a global agent or action
+ * editor with only main settings unsaved keeps the editors' own discard prompt, as the
+ * editors do when they are left with changes; anything else asks with the page's dialog.
+ */
+ const leaveGuard = useRef({ unsaved: false, sending: false });
+ leaveGuard.current = {
+ unsaved: dirtyKeys.length > 0 || dmDirtyCount > 0 || dmCosmosDirty,
+ sending: saving || dmSaving || dmPendingRequests > 0,
+ };
+ // Whether the navigation being held opens a global agent or action editor. It is worked
+ // out here because this function is given paths relative to the app's base path, while
+ // the location kept on the blocker still carries that base path.
+ const blockedForEditor = useRef(false);
+ const blocker = useBlocker(({ currentLocation, nextLocation }) => {
+ const leaving =
+ currentLocation.pathname !== nextLocation.pathname ||
+ currentLocation.search !== nextLocation.search;
+ const block = leaving && (leaveGuard.current.unsaved || leaveGuard.current.sending);
+ if (block) {
+ blockedForEditor.current = GLOBAL_EDITOR_PATH.test(nextLocation.pathname);
+ }
+ return block;
+ });
+ const unsavedSettings = dirtyKeys.length > 0 || saving;
+ const leavingForEditor =
+ blocker.state === 'blocked' &&
+ blockedForEditor.current &&
+ dmDirtyCount === 0 &&
+ !dmCosmosDirty &&
+ !dmSaving &&
+ dmPendingRequests === 0;
+ useEffect(() => {
+ // A save that finishes while the editor question is open leaves nothing to lose.
+ if (leavingForEditor && !unsavedSettings && blocker.state === 'blocked') {
+ blocker.proceed();
+ }
+ }, [blocker, leavingForEditor, unsavedSettings]);
+
const onBrandingUploaded = useCallback(
(target: string, result: BrandingUploadResponse) => {
setBrandingAssets((current) => ({
@@ -1512,6 +1630,26 @@ export function AdminSettingsPage({ focusSection }: { focusSection?: string } =
help={field.help}
/>
);
+ // Backup & Recovery. Each is a card over the data-management API; their
+ // settings save through the Save bar with everything else (see `save`).
+ case 'data-management-readiness':
+ return ;
+ case 'data-management-backup-runs':
+ return ;
+ case 'data-management-schedule':
+ return ;
+ case 'data-management-storage':
+ return ;
+ case 'data-management-encryption':
+ return ;
+ case 'data-management-migration':
+ return ;
+ case 'data-management-backup-inventory':
+ return ;
+ case 'data-management-cosmos-editor':
+ return ;
+ case 'data-management-jobs':
+ return ;
case 'retention-schedule':
return (
- No settings match “{query}”.
+ {query.trim()
+ ? `No settings match “${query.trim()}”.`
+ : 'This category has no settings to show.'}
+ {leaveGuard.current.sending
+ ? 'Check Job history when you come back before trying the request again.'
+ : 'This includes unsaved Backup & Recovery settings and any open Cosmos DB editor changes.'}
+
+
+ ) : null}
+
Backup & Recovery.
+//
+// The page unmounts a section card whenever the category or search filter hides it, so
+// anything an administrator has invested effort in -- unsaved backup settings, a migration
+// half way through its six steps, an unlocked Cosmos editor with an edited document -- has
+// to live outside the cards. It lives here, for the lifetime of the Admin Settings page:
+// the page calls `reset()` when it unmounts, which is the same lifetime its own draft has.
+//
+// Two rules keep asynchronous work honest:
+//
+// - Every request that completes after a reset is ignored. `reset()` advances an epoch, and
+// anything that writes into the store after awaiting checks it first (`isCurrentEpoch`).
+// - Aborting a write is never treated as having undone it. A queued job may already exist
+// on the server, so callers refresh job history rather than assume a request failed.
+//
+// The data-management settings document is saved through its own API. The page's Save bar
+// counts its unsaved edits and saves them after the main settings; actions that must run
+// against saved settings (queueing, reviews, retention cleanup) go through `ensureReadyFor`.
+
+import { useMemo } from 'react';
+import { create } from 'zustand';
+import {
+ errorMessage,
+ loadDataManagementSettings,
+ saveDataManagementSettings,
+ type BackupFilters,
+ type CosmosContainer,
+ type CosmosQueryItem,
+ type DmDraft,
+ type DmEditableKey,
+ type DmSettings,
+ type JobFilters,
+ type JsonRecord,
+} from '../lib/dataManagement';
+import {
+ DM_DEPENDENT_MAIN_KEYS,
+ FIRST_PAGE,
+ KEY_GENERATION_DEPENDENT_MAIN_KEYS,
+ buildDmSettingsPayload,
+ dmDirtyKeys,
+ dmSectionStatuses,
+ formatCosmosDocument,
+ initialMigrationState,
+ migrationReviewKey,
+ readDmValues,
+ validateDmValues,
+ type DmValues,
+ type MigrationStep,
+ type MigrationWizardState,
+ type PagerState,
+} from '../lib/dataManagementLogic';
+import type { SectionStatus } from '../lib/adminSections';
+
+type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
+
+/** Actions that save pending backup settings first, as the classic page does. */
+export type SaveFirstPurpose =
+ 'queue-backup' | 'retention-cleanup' | 'restore-review' | 'restore-queue' | 'migration-execute';
+
+export type ReadinessPurpose = SaveFirstPurpose | 'generate-key';
+
+export type ReadyResult =
+ | { ok: true }
+ | { ok: false; reason: 'save-all-required'; message: string; keys: string[] }
+ | { ok: false; reason: 'invalid' | 'save-failed' | 'not-loaded'; message: string };
+
+export interface SaveOutcome {
+ ok: boolean;
+ error?: string;
+ invalid?: boolean;
+}
+
+export interface InventoryUiState {
+ filters: Required> & {
+ backupType: '' | 'full' | 'partial';
+ createdFrom: string;
+ createdTo: string;
+ };
+ pager: PagerState;
+ selectedId: string | null;
+}
+
+export interface JobsUiState {
+ filters: Required> & {
+ operation: '' | 'backup' | 'restore' | 'migration' | 'dry_run';
+ createdFrom: string;
+ createdTo: string;
+ };
+ pager: PagerState;
+ selectedId: string | null;
+}
+
+export interface CosmosOpenDocument {
+ container: string;
+ id: string;
+ partitionKey: unknown;
+ partitionKeyPath: string;
+ etag: string | null;
+ original: JsonRecord;
+ text: string;
+ editable: boolean;
+}
+
+export interface CosmosEditorSession {
+ unlocked: boolean;
+ containers: CosmosContainer[];
+ container: string;
+ pageSize: number;
+ query: string;
+ results: CosmosQueryItem[];
+ continuationToken: string | null;
+ queryMode: 'empty' | 'custom' | null;
+ queryStatus: string;
+ document: CosmosOpenDocument | null;
+}
+
+export const INITIAL_INVENTORY: InventoryUiState = {
+ filters: {
+ status: 'available',
+ scheduled: 'all',
+ pageSize: 25,
+ backupType: '',
+ createdFrom: '',
+ createdTo: '',
+ },
+ pager: FIRST_PAGE,
+ selectedId: null,
+};
+
+export const INITIAL_JOBS: JobsUiState = {
+ filters: {
+ status: '',
+ scheduled: 'all',
+ pageSize: 25,
+ operation: '',
+ createdFrom: '',
+ createdTo: '',
+ },
+ pager: FIRST_PAGE,
+ selectedId: null,
+};
+
+export const INITIAL_COSMOS: CosmosEditorSession = {
+ unlocked: false,
+ containers: [],
+ container: '',
+ pageSize: 100,
+ query: '',
+ results: [],
+ continuationToken: null,
+ queryMode: null,
+ queryStatus: '',
+ document: null,
+};
+
+interface DataManagementState {
+ status: LoadStatus;
+ loadError: string | null;
+ /** The saved document as the server last returned it (sanitized, secrets redacted). */
+ settings: DmSettings | null;
+ /** Unsaved edits, keyed by settings key. */
+ draft: DmDraft;
+ saving: boolean;
+ saveError: string | null;
+ fieldErrors: Partial>;
+
+ /** Main-settings keys with unsaved edits, published by the page for the save guard. */
+ mainDirtyKeys: string[];
+ /** The page's save coordinator, which saves main settings and then these. */
+ saveAllHandler: (() => Promise) | null;
+ /** State-changing requests in flight, which the page's navigation guard respects. */
+ pendingRequests: number;
+
+ jobsRevision: number;
+ backupsRevision: number;
+ /** A job another card asked the Jobs workbench to open. */
+ focusJobId: string | null;
+
+ migration: MigrationWizardState;
+ /** A step another card asked the migration workbench to show. */
+ migrationFocusStep: MigrationStep | null;
+ cosmos: CosmosEditorSession;
+ inventory: InventoryUiState;
+ jobs: JobsUiState;
+
+ ensureLoaded: () => Promise;
+ reload: () => Promise;
+ setValue: (key: DmEditableKey, value: unknown) => void;
+ discard: () => void;
+ save: () => Promise;
+ ensureReadyFor: (purpose: ReadinessPurpose) => Promise;
+ rebase: (settings: DmSettings) => void;
+ trackRequest: (promise: Promise) => Promise;
+ notifyJobsChanged: () => void;
+ notifyBackupsChanged: () => void;
+ focusJob: (jobId: string | null) => void;
+ updateMigration: (
+ update: Partial | ((state: MigrationWizardState) => MigrationWizardState),
+ ) => void;
+ resetMigration: () => void;
+ requestMigrationStep: (step: MigrationStep | null) => void;
+ updateCosmos: (
+ update: Partial | ((state: CosmosEditorSession) => CosmosEditorSession),
+ ) => void;
+ lockCosmos: () => void;
+ updateInventory: (
+ update: Partial | ((state: InventoryUiState) => InventoryUiState),
+ ) => void;
+ updateJobs: (update: Partial | ((state: JobsUiState) => JobsUiState)) => void;
+ setMainDirtyKeys: (keys: string[]) => void;
+ registerSaveAll: (handler: (() => Promise) | null) => void;
+ reset: () => void;
+}
+
+function initialState() {
+ return {
+ status: 'idle' as LoadStatus,
+ loadError: null,
+ settings: null,
+ draft: {},
+ saving: false,
+ saveError: null,
+ fieldErrors: {},
+ mainDirtyKeys: [],
+ saveAllHandler: null,
+ pendingRequests: 0,
+ jobsRevision: 0,
+ backupsRevision: 0,
+ focusJobId: null,
+ migration: initialMigrationState(),
+ migrationFocusStep: null,
+ cosmos: { ...INITIAL_COSMOS },
+ inventory: { ...INITIAL_INVENTORY },
+ jobs: { ...INITIAL_JOBS },
+ };
+}
+
+let epoch = 0;
+let loadController: AbortController | null = null;
+let loadPromise: Promise | null = null;
+
+/** The current store lifetime. Capture it before awaiting; compare it before writing back. */
+export function currentEpoch(): number {
+ return epoch;
+}
+
+export function isCurrentEpoch(token: number): boolean {
+ return token === epoch;
+}
+
+function applyUpdate(current: T, update: Partial | ((state: T) => T)): T {
+ return typeof update === 'function' ? (update as (state: T) => T)(current) : { ...current, ...update };
+}
+
+export const useDataManagementStore = create((set, get) => ({
+ ...initialState(),
+
+ ensureLoaded: () => {
+ const { status } = get();
+ if (status === 'ready') return Promise.resolve();
+ if (status === 'loading' && loadPromise) return loadPromise;
+ return get().reload();
+ },
+
+ reload: () => {
+ loadController?.abort();
+ const controller = new AbortController();
+ loadController = controller;
+ const token = epoch;
+ set({ status: 'loading', loadError: null });
+ loadPromise = loadDataManagementSettings(controller.signal)
+ .then((settings) => {
+ if (!isCurrentEpoch(token) || controller.signal.aborted) return;
+ set({ settings, status: 'ready', loadError: null });
+ })
+ .catch((error: unknown) => {
+ if (!isCurrentEpoch(token) || controller.signal.aborted) return;
+ set({
+ status: 'error',
+ loadError: errorMessage(error, 'Backup settings could not be loaded.'),
+ });
+ })
+ .finally(() => {
+ if (loadController === controller) {
+ loadController = null;
+ loadPromise = null;
+ }
+ });
+ return loadPromise;
+ },
+
+ setValue: (key, value) =>
+ set((state) => {
+ const fieldErrors = { ...state.fieldErrors };
+ delete fieldErrors[key];
+ return { draft: { ...state.draft, [key]: value }, fieldErrors, saveError: null };
+ }),
+
+ discard: () => set({ draft: {}, fieldErrors: {}, saveError: null }),
+
+ save: async () => {
+ const state = get();
+ if (!state.settings) {
+ return { ok: false, error: 'Backup settings have not loaded yet.' };
+ }
+ const values = readDmValues(state.settings, state.draft);
+ const errors = validateDmValues(values);
+ if (Object.keys(errors).length) {
+ const message = 'Fix the highlighted Backup & Recovery settings, then save again.';
+ set({ fieldErrors: errors, saveError: message });
+ return { ok: false, error: message, invalid: true };
+ }
+ const submitted = { ...state.draft };
+ const token = epoch;
+ set({ saving: true, saveError: null, fieldErrors: {} });
+ try {
+ const saved = await saveDataManagementSettings(buildDmSettingsPayload(values));
+ if (!isCurrentEpoch(token))
+ return { ok: false, error: 'The page was closed before the save finished.' };
+ // An edit made while the save was in flight was not part of it, so it stays.
+ const remaining: DmDraft = {};
+ for (const [key, value] of Object.entries(get().draft) as [DmEditableKey, unknown][]) {
+ if (!Object.prototype.hasOwnProperty.call(submitted, key) || submitted[key] !== value) {
+ remaining[key] = value;
+ }
+ }
+ // A migration review run against exactly what was just saved is still current,
+ // but the response masks any secret that was typed and the draft entry is gone,
+ // so its key is restated in the saved form. The server still compares its own
+ // fingerprint of the saved settings when the job is queued.
+ const migration = get().migration;
+ const reviewedWhatWasSaved = Boolean(
+ migration.review &&
+ migration.reviewKey &&
+ migration.reviewKey === migrationReviewKey(migration, values),
+ );
+ set({
+ settings: saved,
+ draft: remaining,
+ saving: false,
+ saveError: null,
+ ...(reviewedWhatWasSaved
+ ? {
+ migration: {
+ ...migration,
+ reviewKey: migrationReviewKey(migration, readDmValues(saved, {})),
+ },
+ }
+ : {}),
+ });
+ return { ok: true };
+ } catch (error) {
+ if (!isCurrentEpoch(token))
+ return { ok: false, error: 'The page was closed before the save finished.' };
+ const message = errorMessage(error, 'Backup & Recovery settings could not be saved.');
+ set({ saving: false, saveError: message });
+ return { ok: false, error: message };
+ }
+ },
+
+ ensureReadyFor: async (purpose) => {
+ if (!get().settings) {
+ await get().ensureLoaded();
+ }
+ const state = get();
+ if (!state.settings) {
+ return { ok: false, reason: 'not-loaded', message: 'Backup settings could not be loaded.' };
+ }
+ const savesFirst = purpose !== 'generate-key';
+ const dmDirty = dmDirtyKeys(state.settings, state.draft).length > 0;
+ const watched =
+ purpose === 'generate-key'
+ ? [...DM_DEPENDENT_MAIN_KEYS, ...KEY_GENERATION_DEPENDENT_MAIN_KEYS]
+ : DM_DEPENDENT_MAIN_KEYS;
+ const pendingMain = state.mainDirtyKeys.filter((key) => watched.includes(key));
+ // Backup settings are validated against the saved main settings, so saving them
+ // while a key they are checked against has an unsaved edit would check the wrong
+ // value. Saving everything first resolves it.
+ if (pendingMain.length && (purpose === 'generate-key' || (savesFirst && dmDirty))) {
+ return {
+ ok: false,
+ reason: 'save-all-required',
+ keys: pendingMain,
+ message:
+ purpose === 'generate-key'
+ ? 'Key Vault or Enhanced Citations settings have unsaved changes. Save all changes first so the key is stored where those settings say.'
+ : 'Enhanced Citations storage settings have unsaved changes, and backup storage is checked against them. Save all changes first.',
+ };
+ }
+ if (savesFirst && dmDirty) {
+ const outcome = await get().save();
+ if (!outcome.ok) {
+ return {
+ ok: false,
+ reason: outcome.invalid ? 'invalid' : 'save-failed',
+ message: outcome.error ?? 'Backup & Recovery settings could not be saved.',
+ };
+ }
+ }
+ return { ok: true };
+ },
+
+ rebase: (settings) => set({ settings }),
+
+ trackRequest: async (promise: Promise) => {
+ const token = epoch;
+ set((state) => ({ pendingRequests: state.pendingRequests + 1 }));
+ try {
+ return await promise;
+ } finally {
+ if (isCurrentEpoch(token)) {
+ set((state) => ({ pendingRequests: Math.max(0, state.pendingRequests - 1) }));
+ }
+ }
+ },
+
+ notifyJobsChanged: () => set((state) => ({ jobsRevision: state.jobsRevision + 1 })),
+ notifyBackupsChanged: () => set((state) => ({ backupsRevision: state.backupsRevision + 1 })),
+ focusJob: (jobId) => set({ focusJobId: jobId }),
+
+ updateMigration: (update) => set((state) => ({ migration: applyUpdate(state.migration, update) })),
+ resetMigration: () => set({ migration: initialMigrationState() }),
+ requestMigrationStep: (step) => set({ migrationFocusStep: step }),
+
+ updateCosmos: (update) => set((state) => ({ cosmos: applyUpdate(state.cosmos, update) })),
+ lockCosmos: () => set({ cosmos: { ...INITIAL_COSMOS } }),
+
+ updateInventory: (update) => set((state) => ({ inventory: applyUpdate(state.inventory, update) })),
+ updateJobs: (update) => set((state) => ({ jobs: applyUpdate(state.jobs, update) })),
+
+ setMainDirtyKeys: (keys) => set({ mainDirtyKeys: keys }),
+ registerSaveAll: (handler) => set({ saveAllHandler: handler }),
+
+ reset: () => {
+ epoch += 1;
+ loadController?.abort();
+ loadController = null;
+ loadPromise = null;
+ // Registrations are cleared too. The page resets on unmount and registers again
+ // when it mounts.
+ set({ ...initialState() });
+ },
+}));
+
+/** Every editable value, unsaved edits first. */
+export function useDmValues(): DmValues {
+ const settings = useDataManagementStore((state) => state.settings);
+ const draft = useDataManagementStore((state) => state.draft);
+ return useMemo(() => readDmValues(settings, draft), [settings, draft]);
+}
+
+/** How many backup settings have unsaved edits, for the page's Save bar. */
+export function useDmDirtyCount(): number {
+ const settings = useDataManagementStore((state) => state.settings);
+ const draft = useDataManagementStore((state) => state.draft);
+ return useMemo(() => dmDirtyKeys(settings, draft).length, [settings, draft]);
+}
+
+/** Status chips for the Backup & Recovery cards that have a configured state. */
+export function useDmSectionStatuses(): Partial> {
+ const settings = useDataManagementStore((state) => state.settings);
+ const draft = useDataManagementStore((state) => state.draft);
+ return useMemo(() => dmSectionStatuses(settings, draft), [settings, draft]);
+}
+
+/** Whether the Cosmos editor holds JSON edits that have not been saved to the database. */
+export function selectCosmosEditorDirty(state: { cosmos: CosmosEditorSession }): boolean {
+ const document = state.cosmos.document;
+ return Boolean(document && document.text !== formatCosmosDocument(document.original));
+}
diff --git a/docs/_data/app_surface.yml b/docs/_data/app_surface.yml
index 574e3f086..bdf0498ab 100644
--- a/docs/_data/app_surface.yml
+++ b/docs/_data/app_surface.yml
@@ -644,7 +644,7 @@ admin_sections:
tab: custom-pages
group: appearance
- id: data-management-backup-inventory-section
- label: Backup Inventory & Restore
+ label: Backup Inventory & Restore
tab: restore
group: backup-recovery
- id: data-management-backup-section
diff --git a/docs/admin/backup-recovery.md b/docs/admin/backup-recovery.md
index 3513b33c8..830693da1 100644
--- a/docs/admin/backup-recovery.md
+++ b/docs/admin/backup-recovery.md
@@ -30,156 +30,193 @@ These settings protect the data plane during maintenance and incidents. A backup
- Review restore collision policy with data owners.
- Limit Cosmos editor use to operators who understand partition keys and ETags.
+## How saving works
+
+Backup & Recovery settings are kept in their own settings document, separate from the rest of Admin Settings. In the V2 admin experience they still share the page's Save bar: an edit in any Backup & Recovery card counts as an unsaved change, **Save changes** saves the other Admin Settings first and then the Backup & Recovery settings, and **Discard** drops both.
+
+Several actions run against the saved settings, because the server reads them again and checks review fingerprints against them. Queueing a backup, running retention cleanup, reviewing or queueing a restore, and starting a migration save pending Backup & Recovery changes first, and their buttons say so, for example **Save and queue full backup**. Retrying or resuming a job never saves settings: a backup resumes into the storage it started with, and is refused if the saved storage settings have since moved it somewhere else.
+
+Backup storage is validated against the saved Enhanced Citations storage, and a new encryption key is stored where the saved Key Vault settings say. When one of those settings has an unsaved edit, the action asks to **Save all and continue** instead of checking against a value that is about to change. Leaving the page with unsaved changes, or while a Backup & Recovery request is still being sent, asks for confirmation first.
+
## Backup {#backup}
### Start Here {#data-management-readiness-section}
-The Start Here section belongs to the Backup tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Start Here is the readiness view for the whole group. It checks backup storage, the encryption key, the schedule, the latest full backup, and the restore and migration destination, and each item opens the section that resolves it. It also holds short guides to setup, backups, migration, restore, and RU Boost permissions.
### Backup {#data-management-backup-section}
-The Backup section belongs to the Backup tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Queue a full or partial backup on demand and see the latest completed full and partial backups. A backup runs against the saved settings, so unsaved Backup & Recovery changes are saved before it is queued.
+
+The performance settings set how many Cosmos DB batches and source-file transfers run in parallel, the transfer chunk size, and how many times a failed operation is retried. Source RU Boost temporarily raises eligible throughput on the source Cosmos DB account, up to 10,000 RU/s, and restores the original setting after the backup completes, fails, is canceled, or recovers. It can increase Azure charges and needs Azure Resource Manager permission on the source account.
+
+#### Settings
+
+| Setting | What it does | Default | Notes |
+| --- | --- | --- | --- |
+| Concurrent batch staging | Controls how many Cosmos DB export batches are prepared at the same time. Raise it only when the source account has enough RU headroom; the server clamps it to 1-16. | 4 | `data_management_backup_max_parallel_operations` |
+| Retry attempts | Retries throttled or failed Cosmos DB and AI Search backup work before the resource is recorded as failed. Use a higher value for noisy maintenance windows; the server clamps it to 1-10. | 5 | `data_management_backup_retry_count` |
+| If source RU Boost is unavailable | Chooses whether a backup that asked for source RU Boost should keep running at the current throughput or stop before exporting. | Continue without the boost | `data_management_backup_capacity_failure_policy` |
+| Source RU Boost for backups | Temporarily raises eligible source Cosmos DB throughput for backup jobs, then restores the original throughput when the job completes, fails, is canceled, or recovers. Leave it off unless the source account can absorb the extra cost and the app identity can manage throughput. | Off | `data_management_backup_temporary_source_ru_enabled` |
+| Source RU Boost target | Target throughput for eligible source Cosmos DB targets while backup RU Boost is on. The server clamps the value to 1,000-10,000 RU/s. | 10,000 RU/s | `data_management_backup_temporary_source_ru` |
+| Concurrent file transfers | Copies Enhanced Citation source files in parallel. Increase it for faster file backups only when storage throttling is not a problem; the server clamps it to 1-8 and can temporarily lower active transfers while throttled. | 4 | `data_management_backup_blob_max_parallel_operations` |
+| Transfer chunk size (MiB) | Streams each source file through chunks of this size. Larger chunks can improve throughput but increase memory pressure; the server clamps it to 1-16 MiB. | 8 MiB | `data_management_backup_blob_chunk_size_mib` |
+| File retry attempts | Retries failed source-file transfers before the file is marked failed. Use a higher value when storage is transiently throttled; the server clamps it to 1-10. | 5 | `data_management_backup_blob_retry_count` |
### Schedule {#data-management-schedule-section}
-The Schedule section belongs to the Backup tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Scheduled backups take a full backup at the chosen frequency and UTC start time, with optional daily partial backups in between. A partial backup captures changed items; restoring one brings back the latest captured state and does not replay deletions.
+
+The retention window sets how long backups are kept. Retention cleanup deletes finished backups older than the window but, by default, keeps the newest successful full backup, and each run deletes at most 25 backups. While scheduled backups are on, cleanup also runs on its own. The backup scope chooses what each backup includes: Cosmos DB, AI Search, and Enhanced Citation source files.
+
+#### Settings
+
+| Setting | What it does | Default | Notes |
+| --- | --- | --- | --- |
+| Scheduled backups | Lets SimpleChat queue full backups on the selected cadence and daily partial backups when they are enabled. Keep it off when backups should run only from an admin's manual queue action. | Off | `data_management_enabled` |
+| Full backup frequency | Chooses how often a complete backup snapshot is scheduled: daily, weekly, every 14 days, or every 30 days. | Weekly | `data_management_full_frequency` |
+| Start time (UTC) | Schedules backup jobs at this UTC time. Invalid or out-of-range values are normalized back to 03:00; valid times are 00:00 through 23:59. | 03:00 | `data_management_scheduled_time_utc` |
+| Keep backups for | Number used with the retention unit to compute the retention window. Cleanup deletes completed backups older than that window and, by default, keeps the newest successful full backup; the derived value is capped at 3,650 days. | 30 | `data_management_retention_value` |
+| Retention unit | Unit for the retention value. The server accepts days, weeks, months, and years, using 1, 7, 30, and 365 days respectively, and clamps the value so the final window stays within 3,650 days. | Days | `data_management_retention_unit` |
+| Daily partial backups | Captures changed items each day between full backups. Use it when recovery point freshness matters, knowing a partial restore does not replay deletions. | On | `data_management_partial_enabled` |
+| Use low-impact mode for scheduled jobs | Saves the operator preference alongside the schedule. The current backend stores this value but does not use it to change backup, restore, or migration behavior. | On | `data_management_low_impact_mode` |
+| Cosmos DB records | Includes the Cosmos DB artifact containers defined for data management, covering settings plus workspace, conversation, document, agent, action, prompt, and identity categories. Restore and migration are only meaningful when this surface is included. | On | `data_management_include_cosmos` |
+| AI Search indexes | Includes AI Search index schemas and retrievable indexed documents for personal, group, and public workspaces. Leave it on when restored or migrated chat search should work without rebuilding indexes. | On | `data_management_include_ai_search` |
+| Source document files | Includes original Enhanced Citation source files. The stored default is On, but the server forces it Off and V2 locks the control while Enhanced Citations is disabled. | On when Enhanced Citations is enabled; Off otherwise | `data_management_include_source_blobs` |
### Storage {#data-management-storage-section}
-The Storage section belongs to the Backup tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Backups are written to a container in an Azure Storage account dedicated to backups, under the path prefix. SimpleChat signs in with managed identity or a connection string. While Enhanced Citations is on, saving is refused if backup storage uses the same connection string or Blob endpoint as Enhanced Citations storage.
+
+Test storage checks the values on screen without saving them, and creates the container when it does not exist. Each backup records the container, path prefix, and storage identity it was written with, and restore review rejects a backup when those no longer match.
+
+#### Settings
+
+| Setting | What it does | Default | Notes |
+| --- | --- | --- | --- |
+| Authentication | Selects how SimpleChat reaches the backup storage account. Managed identity uses the Blob endpoint; connection string uses the secret and clears the endpoint when saved. | Managed identity | `data_management_storage_auth` |
+| Blob endpoint | Backup storage account endpoint used with managed identity, for example `https://account.blob.core.windows.net`. Use a storage account dedicated to backups, not the Enhanced Citations account. | Empty | `data_management_blob_endpoint` |
+| Connection string | Backup storage connection string used only when Authentication is Connection string. The server clears it when managed identity is selected and redacts stored values in the admin API. | Empty | `data_management_connection_string` |
+| Container | Container that holds backup artifacts. Test storage creates it if it is missing, and restore review checks older backups against the container recorded when they were written. | `simplechat-backups` | `data_management_container_name` |
+| Path prefix | Folder-like prefix inside the container for backup artifacts. Leading and trailing slashes are stripped, and an empty value is normalized to the default. | `simplechat-backups` | `data_management_path_prefix` |
### Encryption {#data-management-encryption-section}
-The Encryption section belongs to the Backup tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Backup artifacts are encrypted with a generated backup key before they are written. The key is stored in Key Vault when Key Vault secret storage is configured, and otherwise in the Backup & Recovery settings. Restore review checks each backup against the encryption mode and key it was written with: turning encryption on or off, or replacing a key stored in settings, makes earlier backups fail review until the settings match again. Backups encrypted with a Key Vault key keep the secret version they used.
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| Enable scheduled backups | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `data_management_enabled` |
-| Full backup frequency | Defines behavior for the related admin workflow; verify the affected feature after saving. | weekly | `data_management_full_frequency` |
-| Scheduled time (UTC) | Default is 03:00 UTC. | 03:00 | `data_management_scheduled_time_utc` |
-| Delete backups after | Defines behavior for the related admin workflow; verify the affected feature after saving. | 30 | `data_management_retention_value` |
-| Retention unit | Defines behavior for the related admin workflow; verify the affected feature after saving. | days | `data_management_retention_unit` |
-| Run partial backups daily between full backups | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `data_management_partial_enabled` |
-| Include Cosmos DB data | Core application records required for meaningful restore and migration. | On | `data_management_include_cosmos` |
-| Include AI Search indexes | Search index schemas and retrievable indexed documents. | On | `data_management_include_ai_search` |
-| Include source document blobs | Original source files used by Enhanced Citations. | Off | `data_management_include_source_blobs` |
-| Authentication | Chooses whether SimpleChat authenticates to this service with a key, managed identity, or another supported method. | managed_identity | `data_management_storage_auth` |
-| Blob endpoint | Provides the endpoint or route SimpleChat uses for this service. | Not specified in defaults | `data_management_blob_endpoint` |
-| Container | Provides displayed text that users see in the affected interface. | simplechat-backups | `data_management_container_name` |
-| Connection string | No connection string saved yet. | Empty | `data_management_connection_string` |
-| Blob prefix | Defines behavior for the related admin workflow; verify the affected feature after saving. | simplechat-backups | `data_management_path_prefix` |
-| Encrypt backup artifacts | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `data_management_encryption_enabled` |
-| Concurrent blob transfers | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 4 | `data_management_backup_blob_max_parallel_operations` |
-| Transfer chunk size (MiB) | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 8 | `data_management_backup_blob_chunk_size_mib` |
-| Blob retry attempts | Defines behavior for the related admin workflow; verify the affected feature after saving. | 5 | `data_management_backup_blob_retry_count` |
-| Concurrent batch staging | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 4 | `data_management_backup_max_parallel_operations` |
-| Retry attempts | Defines behavior for the related admin workflow; verify the affected feature after saving. | 5 | `data_management_backup_retry_count` |
-| If source RU Boost is unavailable | Defines behavior for the related admin workflow; verify the affected feature after saving. | continue_without_boost | `data_management_backup_capacity_failure_policy` |
-| Enable source RU Boost for backups | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `data_management_backup_temporary_source_ru_enabled` |
-| Source RU Boost target | Defines behavior for the related admin workflow; verify the affected feature after saving. | 10000 | `data_management_backup_temporary_source_ru` |
-| Authentication | Chooses whether SimpleChat authenticates to this service with a key, managed identity, or another supported method. | managed_identity | `data_management_target_cosmos_auth` |
-| Endpoint | Provides the endpoint or route SimpleChat uses for this service. | Not specified in defaults | `data_management_target_cosmos_endpoint` |
-| Database | Fixed app contract. | Not specified in defaults | `data_management_target_cosmos_database` |
-| Account key | Provides the secret credential used when the selected authentication mode requires one. | Empty | `data_management_target_cosmos_key` |
-| Authentication | Chooses whether SimpleChat authenticates to this service with a key, managed identity, or another supported method. | managed_identity | `data_management_target_ai_search_auth` |
-| Endpoint | Provides the endpoint or route SimpleChat uses for this service. | N/A (runtime control) | `data_management_target_ai_search_endpoint` |
-| Admin key | Provides the secret credential used when the selected authentication mode requires one. | N/A (runtime control) | `data_management_target_ai_search_key` |
-| Authentication | Chooses whether SimpleChat authenticates to this service with a key, managed identity, or another supported method. | managed_identity | `data_management_target_ec_storage_auth` |
-| Blob endpoint | Provides the endpoint or route SimpleChat uses for this service. | Not specified in defaults | `data_management_target_ec_blob_endpoint` |
-| Connection string | Provides the secret credential used when the selected authentication mode requires one. | Empty | `data_management_target_ec_connection_string` |
-| Subscription ID | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_target_cosmos_subscription_id` |
-| Resource group | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_target_cosmos_resource_group` |
-| Status | Narrows the admin list shown for status. | N/A (runtime control) | `data_management_backup_status_filter` |
-| Run type | Narrows the admin list shown for run type. | N/A (runtime control) | `data_management_backup_scheduled_filter` |
-| Created from | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_backup_created_from` |
-| Created through | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_backup_created_to` |
-| Rows per page | Defines a capacity or timing boundary that keeps the feature inside supported limits. | Not specified in defaults | `data_management_backup_page_size` |
+| Encrypt backup files | Encrypts every backup artifact before it is written to backup storage. Keep it on unless you are deliberately testing unencrypted artifacts in an isolated environment. | On | `data_management_encryption_enabled` |
## Migrate {#migrate}
### Migration {#data-management-migration-section}
-The Migration section belongs to the Migrate tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Migration copies selected users, groups, and public workspaces, and optionally their documents, into another SimpleChat environment. It runs in six steps: connect the destination Cosmos DB, AI Search, and Enhanced Citation storage; choose the scope; choose the run mode, optional surfaces, and performance limits; run the server preflight review; confirm; and follow progress.
+
+There are three run modes. **Copy missing items only** never updates or deletes existing destination data. **Catch up changed items** also updates changed items that an earlier migration created, and keeps data that exists only in the destination. **Make destination match source** additionally deletes destination items that earlier migrations created and the source no longer has, and requires typing `MAKE DESTINATION MATCH SOURCE`. A migration starts only from a current review: changing the plan or the destination settings afterwards means running the review again. Restore writes into the same destination.
+
+Choosing **All** for users, groups, or public workspaces asks the server for that scope's count and shows it in the Scope step and header. If a count cannot load, **Retry count** asks again; the wizard can still continue, because the server review counts the records itself. When every included scope has been counted and the total is 0, the Scope step blocks review until at least one user, group, or public workspace is included. The Confirm step shows the server-normalized review plan: principal scopes, included documents, synchronization mode, creates and updates, deletes, and conflicts.
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| None Skip users | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_users_mode_choice` |
-| Search users | Narrows the admin list shown for search users. | N/A (runtime control) | `data_management_migration_users_search` |
-| Include users' documents | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_users_documents` |
-| None Skip groups | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_groups_mode_choice` |
-| Search groups | Narrows the admin list shown for search groups. | N/A (runtime control) | `data_management_migration_groups_search` |
-| Include group documents | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_groups_documents` |
-| None Skip public workspaces | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_public_workspaces_mode_choice` |
-| Search public workspaces | Narrows the admin list shown for search public workspaces. | N/A (runtime control) | `data_management_migration_public_workspaces_search` |
-| Include public workspace documents | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_public_workspaces_documents` |
-| Copy missing items only | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_migration_mode` |
-| Previous completed migration job ID | Leave blank to let SimpleChat choose the latest compatible completed migration as the starting point for this catch-up run. | Not specified in defaults | `data_management_migration_baseline_job_id` |
-| Migrate matching AI Search documents | Narrows the admin list shown for migrate matching ai search documents. | N/A (runtime control) | `data_management_migration_include_ai_search` |
-| I confirm external destination AI Search writers are frozen | SimpleChat pauses its own target indexing. Freeze other writers before review. | N/A (runtime control) | `data_management_migration_target_search_writes_frozen` |
-| Migrate selected source document blobs | Requires Enhanced Citation storage at both source and destination. | Not specified in defaults | `data_management_migration_include_source_blobs` |
-| Concurrent operations | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 8 | `data_management_migration_max_parallel_operations` |
-| Retry attempts | Defines behavior for the related admin workflow; verify the affected feature after saving. | 5 | `data_management_migration_retry_count` |
-| Skip prior successes within hours | Defines behavior for the related admin workflow; verify the affected feature after saving. | Setting | `data_management_migration_skip_recent_within_hours` |
-| Enable destination RU Boost for this migration | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `data_management_migration_temporary_destination_ru_enabled` |
-| Destination RU Boost target | Defines behavior for the related admin workflow; verify the affected feature after saving. | 10000 | `data_management_migration_temporary_destination_ru` |
-| Type MAKE DESTINATION MATCH SOURCE to authorize this run | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | `data_management_migration_mirror_confirmation_phrase` |
-| I reviewed the normalized plan, destination checks, warnings, and operational impact. | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | `data_management_migration_final_confirmation` |
+| Destination Cosmos DB authentication | Chooses managed identity or an account key for the destination Cosmos DB account. Managed identity needs Cosmos DB Data Contributor and network access to the destination. | Managed identity | `data_management_target_cosmos_auth` |
+| Destination Cosmos DB endpoint | Destination account endpoint used by migration and restore reviews. Leave it empty until a target SimpleChat environment is ready. | Empty | `data_management_target_cosmos_endpoint` |
+| Destination Cosmos DB database | Fixed database name SimpleChat writes to in the destination environment. It is shown for review but is not editable. | `SimpleChat` | `data_management_target_cosmos_database` |
+| Destination Cosmos DB account key | Secret used only when Destination Cosmos DB authentication is Account key. The server clears it when managed identity is selected and redacts stored values in the admin API. | Empty | `data_management_target_cosmos_key` |
+| Destination AI Search authentication | Chooses managed identity or an admin key for destination AI Search. Managed identity needs permission to inspect and write the expected indexes. | Managed identity | `data_management_target_ai_search_auth` |
+| Destination AI Search endpoint | Search service endpoint used when AI Search documents are included in a migration or restore review. | Empty | `data_management_target_ai_search_endpoint` |
+| Destination AI Search admin key | Secret used only when Destination AI Search authentication is Admin key. The server clears it when managed identity is selected and redacts stored values in the admin API. | Empty | `data_management_target_ai_search_key` |
+| Destination Enhanced Citation storage authentication | Chooses managed identity or a connection string for the destination storage account that receives selected source document files. | Managed identity | `data_management_target_ec_storage_auth` |
+| Destination Enhanced Citation storage Blob endpoint | Destination storage Blob endpoint used with managed identity when source document files are included. | Empty | `data_management_target_ec_blob_endpoint` |
+| Destination Enhanced Citation storage connection string | Secret used only when destination Enhanced Citation storage authentication is Connection string. The server clears it when managed identity is selected and redacts stored values in the admin API. | Empty | `data_management_target_ec_connection_string` |
+| Destination Cosmos subscription ID | Azure subscription for the destination Cosmos DB account. It is required only when testing or using destination RU Boost. | Empty | `data_management_target_cosmos_subscription_id` |
+| Destination Cosmos resource group | Azure resource group for the destination Cosmos DB account. It is required only when testing or using destination RU Boost. | Empty | `data_management_target_cosmos_resource_group` |
+| User migration scope | Runtime choice for skipping users, selecting users from the catalog, or migrating all users. It is part of the reviewed migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_users_mode_choice` |
+| Search users | Filters the server catalog while choosing selected users. The search text is not saved with Backup & Recovery settings. | N/A (runtime control) | `data_management_migration_users_search` |
+| Include users' documents | Adds the selected users' personal document records when the user scope is not skipped. It is reviewed with the migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_users_documents` |
+| Group migration scope | Runtime choice for skipping groups, selecting groups from the catalog, or migrating all groups. It is part of the reviewed migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_groups_mode_choice` |
+| Search groups | Filters the server catalog while choosing selected groups. The search text is not saved with Backup & Recovery settings. | N/A (runtime control) | `data_management_migration_groups_search` |
+| Include group documents | Adds document records for the selected groups when the group scope is not skipped. It is reviewed with the migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_groups_documents` |
+| Public workspace migration scope | Runtime choice for skipping public workspaces, selecting workspaces from the catalog, or migrating all public workspaces. It is part of the reviewed migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_public_workspaces_mode_choice` |
+| Search public workspaces | Filters the server catalog while choosing selected public workspaces. The search text is not saved with Backup & Recovery settings. | N/A (runtime control) | `data_management_migration_public_workspaces_search` |
+| Include public workspace documents | Adds document records for selected public workspaces when that scope is not skipped. It is reviewed with the migration plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_public_workspaces_documents` |
+| Migration mode | Runtime choice for copying only missing items, catching up changed items, or making the destination match the source. The wizard starts on Copy missing items only and the selected mode is reviewed before execution. | N/A (runtime control) | `data_management_migration_mode` |
+| Previous completed migration job ID | Optional baseline for catch-up and mirror runs. Leaving it blank lets SimpleChat choose the latest compatible completed migration. | N/A (runtime control) | `data_management_migration_baseline_job_id` |
+| Migrate matching AI Search documents | Adds destination AI Search reconciliation for records that match the selected migration scopes. This is part of the reviewed plan and is not saved as a setting. | N/A (runtime control) | `data_management_migration_include_ai_search` |
+| I confirm external destination AI Search writers are frozen | Required acknowledgement when AI Search documents are included. SimpleChat pauses its own target indexing, but admins must freeze other destination writers before review. | N/A (runtime control) | `data_management_migration_target_search_writes_frozen` |
+| Migrate the selected source document files | Copies source document files for the selected scopes. Use it only when Enhanced Citation storage is configured for both source and destination. | N/A (runtime control) | `data_management_migration_include_source_blobs` |
+| Concurrent operations | Limits how many migration operations run at once. Raise it for faster migrations only when the destination can handle the load; the server clamps it to 1-32. | 8 | `data_management_migration_max_parallel_operations` |
+| Retry attempts | Retries failed migration operations before recording them as failed. Increase it for transient destination throttling; the server clamps it to 1-10. | 5 | `data_management_migration_retry_count` |
+| Skip items migrated in the last (hours) | Skips items an earlier run copied successfully within this window. Use it to avoid reprocessing recent successes during resume-style runs; 0 skips nothing, and the server clamps the value to 0-8,760 hours. | 0 | `data_management_migration_skip_recent_within_hours` |
+| Destination RU Boost for this migration | Temporarily raises eligible destination Cosmos DB throughput during migration and restores it after completion or failure. Use only when the destination account can accept the extra cost and the app identity can manage throughput. | Off | `data_management_migration_temporary_destination_ru_enabled` |
+| Destination RU Boost target | Target throughput for eligible destination Cosmos DB targets while migration RU Boost is on. The server clamps the value to 1,000-10,000 RU/s. | 10,000 RU/s | `data_management_migration_temporary_destination_ru` |
+| Type MAKE DESTINATION MATCH SOURCE to authorize this run | Required phrase for mirror migrations because that mode can delete destination items created by earlier migrations when they no longer exist in the source. | N/A (runtime control) | `data_management_migration_mirror_confirmation_phrase` |
+| I reviewed the normalized plan, destination checks, warnings, and operational impact. | Final acknowledgement that enables execution after a current migration review. It is cleared when the plan or destination settings change. | N/A (runtime control) | `data_management_migration_final_confirmation` |
## Restore {#restore}
### Backup Inventory & Restore {#data-management-backup-inventory-section}
-The Backup Inventory & Restore section belongs to the Restore tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Backup Inventory lists backups with their type, contents, warnings, and encryption, filtered by status, run type, and creation date. Selecting a backup shows its manifest and storage details, and the Restore and Delete actions. **Run retention cleanup** applies the retention window on demand.
+
+Restore writes into the destination configured in Migration and is available for completed backups that recorded a manifest. **Create only** never replaces destination data: the review blocks the restore when the destination Cosmos DB containers or AI Search indexes already hold data, and source files that already exist are skipped and reported as collisions. **Overwrite existing** can replace destination data and requires typing `RESTORE WITH OVERWRITE`. A restore is queued only after a passing review that is still current and an acknowledgement; the review authorization expires after 15 minutes.
+
+The destination is required only for the surfaces selected in the Restore dialog. Cosmos DB restores require the destination Cosmos DB endpoint, AI Search restores require the destination AI Search endpoint, and file restores require either the destination Enhanced Citation storage Blob endpoint for managed identity or its connection string for connection-string sign-in. The dialog names any selected surface whose destination is missing before review or queueing.
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| Collision policy | Create-only is non-destructive and recommended for first restore attempts. | create_only | `data_management_restore_policy` |
-| Cosmos DB | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_restore_include_cosmos` |
-| AI Search | Narrows the admin list shown for ai search. | N/A (runtime control) | `data_management_restore_include_ai_search` |
-| Enhanced Citation blobs | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_restore_include_source_blobs` |
-| Type the overwrite confirmation phrase | Required phrase: RESTORE WITH OVERWRITE | N/A (runtime control) | `data_management_restore_overwrite_confirmation_phrase` |
-| I reviewed the restore target, policy, and preflight result. | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | `data_management_restore_final_confirmation` |
+| Status | Filters the backup inventory by availability or job status. The inventory opens on Available so admins see restorable backups first. | N/A (runtime control) | `data_management_backup_status_filter` |
+| Run type | Filters the backup inventory to scheduled backups, manual backups, or both. | N/A (runtime control) | `data_management_backup_scheduled_filter` |
+| Created from | Start date for the backup inventory created-date filter. Leave both dates blank, or choose both dates; an incomplete range, invalid date, end date before the start date, or inclusive range over 366 days pauses loading until corrected. | N/A (runtime control) | `data_management_backup_created_from` |
+| Created through | End date for the backup inventory created-date filter. When both dates are valid, the request sends `created_from` and `created_to` together. | N/A (runtime control) | `data_management_backup_created_to` |
+| Rows per page | Controls backup inventory paging. Available choices are 10, 25, 50, and 100 rows, and the workbench opens at 25. | N/A (runtime control) | `data_management_backup_page_size` |
+| Collision policy | Restore dialog policy for existing destination data. Each dialog opens on Create only; Overwrite existing requires a passing review and the exact overwrite phrase. | N/A (runtime control) | `data_management_restore_policy` |
+| Cosmos DB | Includes Cosmos DB records from the selected backup in the restore plan. The dialog opens with this surface selected, and the choice is reviewed before queueing. | N/A (runtime control) | `data_management_restore_include_cosmos` |
+| AI Search | Includes AI Search artifacts from the selected backup in the restore plan. The dialog opens with this surface selected, and the choice is reviewed before queueing. | N/A (runtime control) | `data_management_restore_include_ai_search` |
+| Enhanced Citation files | Includes source document files from the selected backup in the restore plan. The dialog opens with this surface selected, and the choice is reviewed before queueing. | N/A (runtime control) | `data_management_restore_include_source_blobs` |
+| Type the overwrite confirmation phrase | Required phrase for Overwrite existing restores: `RESTORE WITH OVERWRITE`. It is not part of the review fingerprint, but queueing requires it for destructive restores. | N/A (runtime control) | `data_management_restore_overwrite_confirmation_phrase` |
+| I reviewed the restore target, policy, and preflight result. | Final restore acknowledgement. It is cleared when restore inputs change and is required before queueing a reviewed restore. | N/A (runtime control) | `data_management_restore_final_confirmation` |
## Cosmos Editor {#cosmos-editor}
### Cosmos Editor {#data-management-cosmos-editor-section}
-The Cosmos Editor section belongs to the Cosmos Editor tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+The Cosmos Editor repairs one document at a time in a known SimpleChat Cosmos DB container. It stays locked until an administrator acknowledges the risk, and the acknowledgement, queries, document opens, and saves are recorded in activity logs. A blank query browses the first 100 documents; a custom `SELECT` query can page further. The editor refuses changes to a document's id or partition key, and a save requires typing `I understand this can damage system data`. Saves use the ETag from when the document was opened, so a document changed by someone else in the meantime is not overwritten.
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| Container | Choose a known SimpleChat Cosmos DB container. | Not specified in defaults | `data_management_cosmos_editor_container` |
-| Page size | Max 100 per request. | Not specified in defaults | `data_management_cosmos_editor_page_size` |
-| SELECT query | Empty query returns only the first 100 documents. Custom SELECT queries can page beyond 100 with Next Page. | Not specified in defaults | `data_management_cosmos_editor_query` |
-| I understand this editor can damage overall system health. | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_cosmos_editor_danger_accept` |
-| Data Management Cosmos Editor Document Json | The editor blocks id and partition key changes. Saves use the ETag from the loaded document. | Not specified in defaults | `data_management_cosmos_editor_document_json` |
-| Type the confirmation phrase to enable saving | Required phrase: I understand this can damage system data | N/A (runtime control) | `data_management_cosmos_editor_confirmation_phrase` |
+| Container | Runtime choice of a known SimpleChat Cosmos DB container. Changing containers closes any open JSON draft so edits cannot be applied to the wrong container. | N/A (runtime control) | `data_management_cosmos_editor_container` |
+| Page size | Limits query results per request. The editor starts at 100, and V2 clamps edits to 1-100 before querying. | N/A (runtime control) | `data_management_cosmos_editor_page_size` |
+| SELECT query | Blank browse lists up to the first 100 document summaries. Custom queries must be `SELECT` statements, are limited to 4,000 characters, and can use Next Page when Cosmos returns a continuation token. | N/A (runtime control) | `data_management_cosmos_editor_query` |
+| I understand this editor can damage overall system health. | Unlock acknowledgement for the page session. The server records it in activity logs before live queries are enabled. | N/A (runtime control) | `data_management_cosmos_editor_danger_accept` |
+| Document JSON | Editable JSON for the opened document. Saves are guarded by the document's ETag and are rejected if the id or partition key is changed. | N/A (runtime control) | `data_management_cosmos_editor_document_json` |
+| Type the confirmation phrase to enable saving | Required phrase for saving a JSON edit: `I understand this can damage system data`. It protects the direct production write path. | N/A (runtime control) | `data_management_cosmos_editor_confirmation_phrase` |
## Jobs {#jobs}
### Jobs {#data-management-jobs-section}
-The Jobs section belongs to the Jobs tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned.
+Jobs is the history of backup, restore, migration, and dry-run jobs, newest first, filtered by operation, status, run type, and creation date. Opening a job shows its progress, timeline, artifacts, manifest, and warnings, and follows a running job until it finishes. Retry and Resume continue from durable checkpoints. Cancellation is cooperative: the worker stops at its next durable checkpoint. Migration jobs also offer their manifest, and a manifest of only the failed, missing, and colliding items, for download.
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| Use low impact mode for scheduled jobs | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `data_management_low_impact_mode` |
-| Operation | Narrows the admin list shown for operation. | N/A (runtime control) | `data_management_job_operation_filter` |
-| Status | Narrows the admin list shown for status. | N/A (runtime control) | `data_management_job_status_filter` |
-| Run type | Narrows the admin list shown for run type. | N/A (runtime control) | `data_management_job_scheduled_filter` |
-| Created from | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_job_created_from` |
-| Created through | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `data_management_job_created_to` |
-| Rows per page | Defines a capacity or timing boundary that keeps the feature inside supported limits. | Not specified in defaults | `data_management_job_page_size` |
+| Operation | Filters job history to backup, migration, restore, dry run, or all operations. | N/A (runtime control) | `data_management_job_operation_filter` |
+| Status | Filters job history by queued, running, completed, completed with warnings, failed, or canceled status. | N/A (runtime control) | `data_management_job_status_filter` |
+| Run type | Filters job history to scheduled jobs, manual jobs, or both. | N/A (runtime control) | `data_management_job_scheduled_filter` |
+| Created from | Start date for the job-history created-date filter. Leave both dates blank, or choose both dates; an incomplete range, invalid date, end date before the start date, or inclusive range over 366 days pauses loading until corrected. | N/A (runtime control) | `data_management_job_created_from` |
+| Created through | End date for the job-history created-date filter. When both dates are valid, the request sends `created_from` and `created_to` together. | N/A (runtime control) | `data_management_job_created_to` |
+| Rows per page | Controls job-history paging. Available choices are 10, 25, 50, and 100 rows, and the workbench opens at 25. | N/A (runtime control) | `data_management_job_page_size` |
## Common tasks
@@ -192,6 +229,11 @@ The Jobs section belongs to the Jobs tab. Use it with the adjacent settings in t
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| A restore cannot be queued | Preflight found target access, manifest, or collision-policy problems. | Resolve the reported check before queueing. |
+| Restore says a destination is missing | One of the selected restore surfaces does not have its matching destination setting configured in Migration. | Set only the destination the selected surface needs, then rerun the review. |
+| Saving reports that backup storage must use a dedicated Azure Storage account | Enhanced Citations is on and backup storage uses its connection string or Blob endpoint. | Point backup storage at a separate storage account. |
+| An action asks to **Save all and continue** | Enhanced Citations storage or Key Vault settings have unsaved edits that the action is checked against. | Save all changes, or discard those edits, then run the action. |
+| Restore review rejects an older backup | The backup storage container, path prefix, storage identity, encryption mode, or a settings-stored key changed after the backup was written. | Change the settings back, or restore from a backup written with the current settings. |
+| **Start migration** is unavailable, or returns to the Review step | The plan or destination settings changed after the review, or the review's 15-minute authorization expired. | Run the preflight review again, then confirm. |
## Related
diff --git a/docs/explanation/features/V2_ADMIN_BACKUP_RECOVERY.md b/docs/explanation/features/V2_ADMIN_BACKUP_RECOVERY.md
new file mode 100644
index 000000000..2dd58aee1
--- /dev/null
+++ b/docs/explanation/features/V2_ADMIN_BACKUP_RECOVERY.md
@@ -0,0 +1,182 @@
+# V2 Admin Backup & Recovery
+
+## Overview
+
+V2 Admin Settings listed the Backup & Recovery group, but selecting it showed
+"No settings match". The group's nine sections had no fields to render, because the
+classic page draws them with its own JavaScript over a separate data-management API.
+An administrator using V2 could not back up, restore, migrate, repair a Cosmos DB
+document, or follow a job without switching to the classic page.
+
+This change gives every section a V2 card with the same capabilities as the classic
+page, built in the V2 design language: section cards with a status chip, list-and-detail
+workbenches like the Model Catalog, the page's Save bar, and typed confirmations for
+destructive actions. The copy is written for V2 rather than carried over word for word.
+
+## Implemented in version: **0.261.275**
+
+The application version is maintained in `application/single_app/config.py`.
+
+**Dependencies:** the React/TypeScript V2 UI, `zustand`, local `lucide-react` icons,
+Tailwind container queries, `admin_settings_nav.py`, `admin_settings_fields.py`, and the
+existing admin-only `/api/admin/data-management/*` routes, which are unchanged. No new
+packages, settings, routes, or browser asset sources are required.
+
+## Technical specifications
+
+### Architecture
+
+Each navigation section declares one `component` field in `admin_settings_fields.py`,
+and `AdminSettingsPage.tsx` renders it through a `case` branch. `SettingsSection` draws
+the card shell (title, icon, status chip); each component renders only the card body.
+
+| Section | Component | What the card does |
+| --- | --- | --- |
+| Start Here | `data-management-readiness` | Readiness checklist with links to each section, operational warning, guides |
+| Backup | `data-management-backup-runs` | Queue full or partial backups, latest results, backup performance and source RU Boost |
+| Schedule | `data-management-schedule` | Schedule, retention, partial backups, backup scope |
+| Storage | `data-management-storage` | Backup storage account, container and path prefix, storage test |
+| Encryption | `data-management-encryption` | Encryption switch, key storage readout, key generation and replacement |
+| Migration | `data-management-migration` | Six-step migration wizard and live progress |
+| Backup Inventory & Restore | `data-management-backup-inventory` | Backup workbench, restore dialog, delete, retention cleanup |
+| Cosmos Editor | `data-management-cosmos-editor` | Locked JSON editor with query, open, and guarded save |
+| Jobs | `data-management-jobs` | Job history workbench with live detail, retry or resume, and cancel |
+
+State lives in `stores/dataManagementStore.ts`, a zustand store that outlives individual
+cards. The page unmounts a card whenever a category or search hides it, so unsaved
+settings, a migration in progress, and an unlocked Cosmos editor document are kept in
+the store for the lifetime of the Admin Settings page. The page calls `reset()` when it
+unmounts. `reset()` advances an epoch, and every asynchronous write checks the epoch
+first, so a request that finishes after the administrator leaves cannot write into the
+next visit.
+
+The section status chips come from `dmSectionStatuses`: the schedule is off or ready,
+storage is incomplete or ready, encryption is off, incomplete, or ready, and Backup is
+blocked while storage is missing.
+
+### Save model
+
+The data-management settings are their own document, saved with
+`PUT /api/admin/data-management/settings`.
+
+- **One Save bar.** Unsaved Backup & Recovery edits count in the page's Save bar.
+ **Save changes** sends the main settings `PATCH` first and the Backup & Recovery `PUT`
+ second, and reports which part failed if one does. **Discard** clears both drafts.
+- **The whole document, as the classic page sends it.** The `PUT` carries exactly the 42
+ keys the classic `collectSettings()` sends. The server stores any extra key it is
+ given, so nothing else is ever sent, including `encryption_key_reference`. Only the
+ credential for the selected sign-in mode is sent, and an untouched secret is sent as
+ the `***REDACTED***` placeholder, which keeps the stored value.
+- **Save first.** Queueing a backup, running retention cleanup, reviewing or queueing a
+ restore, and starting a migration act on the saved settings: the server reads them
+ again and recomputes review fingerprints from them. These actions save pending
+ Backup & Recovery edits first, as the classic page does, and their buttons say so.
+ Retry and Resume never save, because a resumed backup is refused if its storage
+ settings have changed since it was queued.
+- **Cross-document guard.** Backup storage is validated against the *saved* Enhanced
+ Citations storage settings, and a generated key is stored where the *saved* Key Vault
+ settings say. When `enable_enhanced_citations`, `office_docs_storage_account_url`,
+ `office_docs_storage_account_blob_endpoint`, `enable_key_vault_secret_storage`, or
+ `key_vault_name` has an unsaved edit, the action offers **Save all and continue**
+ instead of being checked against a value that is about to change.
+- **Leaving the page.** `useBlocker` asks before a client-side navigation leaves unsaved
+ changes, an edited Cosmos document, or a request that is still being sent.
+
+### Reviews, confirmations, and concurrency
+
+| Workflow | Contract |
+| --- | --- |
+| Restore | The review is keyed to the backup, policy, selected surfaces, and the matching destination endpoints and sign-in modes. Only selected surfaces require destinations: Cosmos DB needs the target Cosmos DB endpoint, AI Search needs the target AI Search endpoint, and source files need the target Enhanced Citation storage Blob endpoint or connection string for the selected storage sign-in mode. Queueing needs a passing, current review, its authorization token (15 minutes), an acknowledgement, and `RESTORE WITH OVERWRITE` for the overwrite policy. Each opening of the dialog starts from a clean draft. |
+| Migration | The review is keyed to the plan and the destination and migration settings. Choosing All for a principal type requests a server count; a failed count offers **Retry count** and does not block the step, because the server review counts the records itself. When all included types are counted and total 0, the Scope step blocks review. The Confirm step displays the server-normalized review summary for principal scopes, included documents, synchronization mode, creates and updates, deletes, and conflicts. The mirror phrase `MAKE DESTINATION MATCH SOURCE` is entered after the review and is not part of the key, matching the server's fingerprint. A typed destination secret is masked when settings are saved; the review stays current because it was run against exactly what was saved, and the server still compares its own fingerprint. A `409` or a `workflow_step` of `review` returns to the Review step with the server's reason. |
+| Cosmos Editor | Unlocking records an acknowledgement. Saves send the opened ETag, `confirmation_accepted`, and `I understand this can damage system data`; a `409` offers a reload. Editing the query or page size drops the previous continuation token. A failed or empty container list waits for **Load containers** rather than retrying on its own. |
+| Jobs | Detail and progress requests are applied only while the same job is selected. A running job is polled every 2 seconds until it finishes. Retry, Resume, and Cancel follow the server's `can_retry` and `can_cancel`. |
+
+### API endpoints
+
+All endpoints are the existing admin-only routes in `route_backend_data_management.py`.
+
+| Method | Path | Used for |
+| --- | --- | --- |
+| `GET`, `PUT` | `/api/admin/data-management/settings` | Load and save the settings document |
+| `POST` | `/api/admin/data-management/encryption-key` | Generate or replace the backup key |
+| `POST` | `/api/admin/data-management/storage/test` | Test backup storage without saving |
+| `POST` | `/api/admin/data-management/target/cosmos/test`, `/target/cosmos/ru-boost/test`, `/target/search/test`, `/target/enhanced-citation-storage/test` | Test the migration and restore destination |
+| `GET` | `/api/admin/data-management/backups` | Backup inventory and the latest-backup summary |
+| `POST` | `/api/admin/data-management/backups/retention/cleanup` | Run retention cleanup |
+| `DELETE` | `/api/admin/data-management/backups/` | Delete one backup |
+| `POST` | `/api/admin/data-management/restore/review` | Restore preflight review |
+| `POST` | `/api/admin/data-management/migration/review` | Migration preflight review |
+| `GET` | `/api/admin/data-management/migration/catalog/` | Search users, groups, and public workspaces |
+| `GET`, `POST` | `/api/admin/data-management/jobs` | Job history; queue a backup, restore, or migration |
+| `GET` | `/api/admin/data-management/jobs/`, `/jobs//progress` | Job detail and live progress |
+| `POST` | `/api/admin/data-management/jobs//retry`, `/jobs//cancel` | Retry or resume; request cancellation |
+| `GET` | `/api/admin/data-management/jobs//migration-manifest` | Download a migration manifest |
+| `POST`, `GET`, `PUT` | `/api/admin/data-management/cosmos-editor/*` | Acknowledge, list containers, query, open, and save |
+
+### File structure
+
+| File | Purpose |
+| --- | --- |
+| `application/v2_ui/src/lib/dataManagement.ts` | API wrappers, response types, phrases, editable keys, defaults, and bounds |
+| `application/v2_ui/src/lib/dataManagementLogic.ts` | Pure logic: values and payload, validation, statuses, review keys, gates, formatting |
+| `application/v2_ui/src/lib/dataManagementFields.ts` | Field declarations and V2 copy for the settings the cards edit |
+| `application/v2_ui/src/stores/dataManagementStore.ts` | Shared state, save, save-first readiness, epoch |
+| `application/v2_ui/src/components/admin/dataManagement/` | The nine cards, the restore dialog, migration steps, job detail, guides, and shared parts |
+| `application/v2_ui/src/pages/AdminSettingsPage.tsx` | Renderer cases, combined Save bar, save coordinator, leave guard |
+| `application/single_app/admin_settings_fields.py` | One `component` field per Backup & Recovery section, with search keywords |
+| `application/single_app/admin_settings_nav.py` | The "Backup Inventory & Restore" label no longer shows a literal `&` |
+
+`adminFields.ts` adds an optional `keywords` list to a field, so the settings search
+finds a card by the settings it contains.
+
+## Usage
+
+1. Open **Admin Settings** and choose **Backup & Recovery**. Start Here shows what is
+ ready and opens the section for anything that is not.
+2. Configure **Storage** and **Encryption**, then queue a full backup from **Backup**
+ and check that it appears in **Backup Inventory & Restore**.
+3. Turn on **Schedule** when manual backups work, and set the retention window.
+4. To restore, select a backup in the inventory, choose **Restore…**, run the review,
+ acknowledge it, and queue the restore. The job opens in **Jobs**.
+5. To migrate, follow the Migration steps in order. The Confirm step opens only after a
+ current review.
+
+The full administrator reference is `docs/admin/backup-recovery.md`.
+
+## Testing and validation
+
+### Coverage
+
+- `functional_tests/test_v2_admin_backup_recovery.py` checks that every component is
+ declared, searchable, and routed; that the Save bar saves main settings first and
+ guards navigation; that the new code uses no HTML sinks; and runs the 38 checks in
+ `test_v2_admin_backup_recovery_logic.ts` against the real logic module. Every declared
+ check must report `ok`.
+- `functional_tests/test_v2_admin_backup_recovery_parity.py` compares the editable keys,
+ defaults, and number bounds with the server.
+- `functional_tests/test_v2_admin_capability_placement.py` and
+ `test_v2_admin_field_renderer_coverage.py` include the new group.
+- `ui_tests/test_v2_admin_backup_recovery.py` runs the V2 browser coverage against an in-memory
+ copy of the data-management API (`ui_tests/fixtures/v2_admin_data_management.py`): every
+ card in light and dark themes; the Save bar, discard, and a rejected save; save-first
+ ordering for backups, retention cleanup, restore, and migration; retry never saving;
+ the cross-document guard for backup storage and key generation; restore and migration
+ reviews, staleness, phrases, and `409` handling; job polling and cancellation; the
+ Cosmos editor's unlock, save, conflict, container retry, and continuation token; the
+ leave guard; switching categories without inventing changes or losing a pending edit;
+ and no overflow at 390px or 1920px.
+
+### Performance
+
+Inventory and job history load when their card first scrolls into view. Start Here and
+Backup share one latest-backup read per page visit, refreshed after a job changes the
+inventory. Job progress is polled every 2 seconds only while a selected job is running,
+and the Migration progress step does the same for its job.
+
+### Known limitations
+
+- `low_impact_mode` is stored but not read by the backend, so its help text makes no
+ claim about what it changes.
+- Migration and restore use the same destination settings, so preparing one changes the
+ other.
+- A review authorization lasts 15 minutes; after that the review must be run again.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index e4c91a05a..0237f408e 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,22 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.275)**
+
+#### New Features
+
+* **Backup & Recovery in V2 Admin Settings**
+ * The Backup & Recovery group no longer shows "No settings match". Each of its nine sections now has a V2 card with the same capabilities as the classic page: a Start Here readiness checklist with guides, on-demand full and partial backups, the schedule and retention window, backup storage and encryption, a six-step migration wizard, a backup inventory with restore, the Cosmos DB editor, and job history.
+ * Backup settings join the page's Save bar and save after the other Admin Settings. Queueing a backup, running retention cleanup, reviewing or queueing a restore, and starting a migration save pending Backup & Recovery changes first; retrying or resuming a job never does. When unsaved Enhanced Citations or Key Vault settings would change how an action is checked, the action offers **Save all and continue**.
+ * Restore and migration queue only from a current, passing review, with typed confirmations for overwrite restores and mirror migrations. Cosmos DB editor saves are ETag-guarded, and leaving the page with unsaved work asks first.
+ * (Ref: `components/admin/dataManagement/`, `dataManagementStore.ts`, `dataManagement.ts`, `dataManagementLogic.ts`, `AdminSettingsPage.tsx`, `admin_settings_fields.py`, [V2 Admin Backup & Recovery](features/V2_ADMIN_BACKUP_RECOVERY.md))
+
+#### Bug Fixes
+
+* **Backup Inventory & Restore Label**
+ * The navigation label showed a literal `&` on both the classic and V2 admin pages. It now reads "Backup Inventory & Restore".
+ * (Ref: `admin_settings_nav.py`, `docs/_data/app_surface.yml`)
+
### **(v0.261.274)**
#### New Features
diff --git a/functional_tests/test_v2_admin_backup_recovery.py b/functional_tests/test_v2_admin_backup_recovery.py
new file mode 100644
index 000000000..4587cad20
--- /dev/null
+++ b/functional_tests/test_v2_admin_backup_recovery.py
@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+# test_v2_admin_backup_recovery.py
+"""
+Functional test for the V2 Admin Settings Backup & Recovery surface.
+Version: 0.261.275
+Implemented in: 0.261.275
+
+The V2 admin page used to show Backup & Recovery as an empty category. It now renders
+one card per navigation section, backed by the classic data-management API, and its
+settings save through the page's Save bar after the main settings.
+
+The Python checks here are structural: that the page routes every Backup & Recovery
+component, saves main settings before the separate data-management document, counts
+both in the Save bar, guards in-app navigation, and that the new code uses no unsafe
+HTML sinks. The behavioural half -- the save payload, unsaved-edit detection, review
+staleness, step gates, typed confirmations, paging and the store's save-first rules --
+is executed by the companion TypeScript checks this file bundles and runs.
+"""
+
+import importlib.util
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import import_app_module
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+V2_DIR = REPO_ROOT / "application" / "v2_ui"
+V2_SRC = V2_DIR / "src"
+PAGE_TSX = V2_SRC / "pages" / "AdminSettingsPage.tsx"
+DM_COMPONENTS = V2_SRC / "components" / "admin" / "dataManagement"
+DM_LIB_FILES = (
+ V2_SRC / "lib" / "dataManagement.ts",
+ V2_SRC / "lib" / "dataManagementLogic.ts",
+ V2_SRC / "lib" / "dataManagementFields.ts",
+ V2_SRC / "stores" / "dataManagementStore.ts",
+)
+LOGIC_CHECK_TS = Path(__file__).resolve().parent / "test_v2_admin_backup_recovery_logic.ts"
+
+DM_COMPONENT_NAMES = (
+ "data-management-readiness",
+ "data-management-backup-runs",
+ "data-management-schedule",
+ "data-management-storage",
+ "data-management-encryption",
+ "data-management-migration",
+ "data-management-backup-inventory",
+ "data-management-cosmos-editor",
+ "data-management-jobs",
+)
+
+fields_module = import_app_module("admin_settings_fields")
+
+
+def _read(path):
+ assert path.is_file(), f"Missing expected file: {path}"
+ return path.read_text(encoding="utf-8")
+
+
+def test_every_backup_component_is_declared_and_routed():
+ """A declared card with no renderer branch would leave the category empty again."""
+ print("Testing Backup & Recovery component declarations and routing...")
+
+ assert_app_version_at_least("0.261.275")
+
+ declared = {
+ field.get("component"): section_id
+ for section_id, field in fields_module.iter_fields()
+ if field.get("type") == "component"
+ and str(field.get("component", "")).startswith("data-management-")
+ }
+ assert set(declared) == set(DM_COMPONENT_NAMES), (
+ "The Backup & Recovery components declared in admin_settings_fields.py changed: "
+ f"{sorted(declared)}"
+ )
+
+ page = _read(PAGE_TSX)
+ missing = [name for name in DM_COMPONENT_NAMES if f"case '{name}':" not in page]
+ assert not missing, f"AdminSettingsPage.tsx has no branch for: {missing}"
+
+ for field_section, field in (
+ (section_id, field)
+ for section_id, field in fields_module.iter_fields()
+ if field.get("component") in DM_COMPONENT_NAMES
+ ):
+ keywords = field.get("keywords")
+ assert isinstance(keywords, list) and keywords, (
+ f"{field_section} should carry search keywords; a component has one label and "
+ "the page search would otherwise miss what the card contains."
+ )
+
+ print(f" All {len(DM_COMPONENT_NAMES)} components are declared, searchable and routed.")
+ return True
+
+
+def test_the_save_bar_saves_main_settings_before_backup_settings():
+ """Backup storage is validated against the saved Enhanced Citations settings."""
+ print("\nTesting the page's save coordinator...")
+
+ page = _read(PAGE_TSX)
+
+ assert "dirtyCount={dirtyKeys.length + dmDirtyCount}" in page, (
+ "The Save bar should count unsaved Backup & Recovery settings with the main ones."
+ )
+ assert "useDataManagementStore.getState().discard()" in page, (
+ "Discard should also discard unsaved Backup & Recovery settings."
+ )
+
+ save_body = page[page.index("const save = useCallback(async () => {"):]
+ save_body = save_body[: save_body.index("}, [draft, refreshBootstrap]);")]
+ main_index = save_body.find("api.patch")
+ dm_index = save_body.find("useDataManagementStore.getState().save()")
+ assert main_index != -1 and dm_index != -1, "The save coordinator lost one of its phases."
+ assert main_index < dm_index, (
+ "Main settings must be saved before Backup & Recovery settings, because the "
+ "data-management save validates against the saved Enhanced Citations storage."
+ )
+ assert "return false;" in save_body[main_index:dm_index], (
+ "A failed main save should stop before saving Backup & Recovery settings."
+ )
+ assert "useDataManagementStore.getState().setMainDirtyKeys([]);" in save_body[main_index:dm_index], (
+ "A successful main save must clear the unsaved main keys before it resolves: an "
+ "action waiting on Save all checks them again straight away, before React re-renders."
+ )
+
+ assert "registerSaveAll(save)" in page and "setMainDirtyKeys(dirtyKeys)" in page, (
+ "The cards need the page's save coordinator and the unsaved main keys for the "
+ "save-first guard."
+ )
+ assert "useBlocker(" in page, (
+ "In-app navigation away from unsaved changes should ask first; the Save bar's "
+ "unload prompt only covers closing the tab."
+ )
+ assert page.count("useBlocker(") == 1, (
+ "A router honours one blocker at a time and silently ignores the others, so the "
+ "page must guard every way out with a single useBlocker."
+ )
+ assert "useDataManagementStore.getState().reset()" in page, (
+ "Leaving the page should reset the Backup & Recovery store, as the main draft is."
+ )
+
+ print(" Main settings save first, both drafts count, and leaving is guarded.")
+ return True
+
+
+def test_backup_recovery_code_uses_no_html_sinks():
+ """Server text is rendered as text; download links are built from a literal prefix."""
+ print("\nTesting Backup & Recovery code for unsafe sinks...")
+
+ files = sorted(DM_COMPONENTS.rglob("*.tsx")) + list(DM_LIB_FILES)
+ assert len(files) >= 10, f"Expected the Backup & Recovery files, found {len(files)}"
+
+ forbidden = re.compile(
+ r"dangerouslySetInnerHTML|\.innerHTML|outerHTML|insertAdjacentHTML|javascript:"
+ r"|(https?:)?//(cdn|unpkg|jsdelivr|cdnjs)",
+ re.IGNORECASE,
+ )
+ offenders = []
+ for path in files:
+ for line_number, line in enumerate(_read(path).splitlines(), start=1):
+ if forbidden.search(line):
+ offenders.append(f"{path.relative_to(V2_SRC)}:{line_number}: {line.strip()[:100]}")
+ assert not offenders, "Unsafe sinks in Backup & Recovery code:\n " + "\n ".join(offenders)
+
+ href_values = []
+ for path in sorted(DM_COMPONENTS.rglob("*.tsx")):
+ href_values.extend(re.findall(r"href=\{([^}]*)\}", _read(path)))
+ unsafe = [value for value in href_values if "migrationManifestUrl(" not in value]
+ assert not unsafe, (
+ "Backup & Recovery links must come from migrationManifestUrl(), which builds a "
+ f"same-origin path from a literal prefix and an encoded id: {unsafe}"
+ )
+
+ # The repository's XSS sink checker approves the builder by name, so its shape is
+ # pinned here: a literal API prefix, an encoded job id and a literal, encoded query.
+ lib = _read(V2_SRC / "lib" / "dataManagement.ts")
+ assert "export const DM_API = '/api/admin/data-management';" in lib
+ builder = lib[lib.index("export function migrationManifestUrl("):]
+ builder = builder[: builder.index("\n}\n")]
+ assert "apiUrl(`${DM_API}/jobs/${encodeURIComponent(jobId)}/migration-manifest${query}`)" in builder, (
+ "migrationManifestUrl must stay a same-origin path with an encoded job id"
+ )
+ assert "`?statuses=${encodeURIComponent(MANIFEST_FAILURE_STATUSES)}`" in builder
+ spec = importlib.util.spec_from_file_location("check_xss_sinks", REPO_ROOT / "scripts" / "check_xss_sinks.py")
+ assert spec is not None and spec.loader is not None, "Expected a module spec for check_xss_sinks.py"
+ checker = importlib.util.module_from_spec(spec)
+ previous = sys.modules.get(spec.name)
+ # Registered while it runs, because the checker's dataclasses look their module up.
+ sys.modules[spec.name] = checker
+ try:
+ spec.loader.exec_module(checker)
+ approved = "migrationManifestUrl" in checker.TS_SAME_ORIGIN_URL_BUILDERS
+ # Run the checker over every Backup & Recovery source file, as CI does.
+ issues = []
+ for path in files:
+ issues.extend(checker.format_error_annotation(issue) for issue in checker.inspect_file(path))
+ finally:
+ if previous is None:
+ sys.modules.pop(spec.name, None)
+ else:
+ sys.modules[spec.name] = previous
+ assert approved, "The XSS sink checker should list migrationManifestUrl as a reviewed same-origin URL builder"
+ assert not issues, "The XSS sink checker reports Backup & Recovery issues:\n " + "\n ".join(issues)
+
+ print(f" {len(files)} file(s) clean; {len(href_values)} dynamic link(s) all built safely.")
+ return True
+
+
+def test_the_typescript_logic_checks_pass():
+ """Execute the behavioural half; a missing front-end toolchain is a visible skip."""
+ print("\nTesting Backup & Recovery logic (TypeScript)...")
+
+ if not (V2_DIR / "node_modules").exists():
+ reason = "application/v2_ui/node_modules is absent; run npm ci there to include these checks"
+ if os.environ.get("PYTEST_CURRENT_TEST"):
+ import pytest
+
+ pytest.skip(reason)
+ print(f" skip {reason}")
+ return True
+
+ assert LOGIC_CHECK_TS.exists(), "The TypeScript logic checks are missing"
+ declared = len(re.findall(r"^check\(", LOGIC_CHECK_TS.read_text(encoding="utf-8"), re.MULTILINE))
+ assert declared >= 38, f"expected the logic check file to declare its checks, found {declared}"
+
+ # functional_tests/ has no node_modules of its own, so the bundle is written where
+ # node can resolve bare imports from.
+ bundle = V2_DIR / "node_modules" / ".cache-admin-backup-recovery-check.mjs"
+ try:
+ subprocess.run(
+ [
+ "npx",
+ "esbuild",
+ str(LOGIC_CHECK_TS),
+ "--bundle",
+ "--platform=node",
+ "--format=esm",
+ "--packages=external",
+ "--define:import.meta.env={}",
+ f"--outfile={bundle}",
+ "--log-level=error",
+ ],
+ cwd=str(V2_DIR),
+ check=True,
+ shell=(sys.platform == "win32"),
+ )
+ result = subprocess.run(
+ ["node", str(bundle)],
+ cwd=str(V2_DIR),
+ capture_output=True,
+ text=True,
+ shell=(sys.platform == "win32"),
+ )
+ finally:
+ if bundle.exists():
+ bundle.unlink()
+
+ if result.returncode != 0:
+ print(result.stdout)
+ print(result.stderr)
+ raise AssertionError("the Backup & Recovery TypeScript logic checks failed")
+
+ passed = result.stdout.count(" ok ")
+ if passed != declared:
+ print(result.stdout)
+ raise AssertionError(f"{passed} of {declared} declared TypeScript logic checks reported ok")
+ print(f" ok {passed} TypeScript logic checks passed")
+ return True
+
+
+if __name__ == "__main__":
+ tests = [
+ test_every_backup_component_is_declared_and_routed,
+ test_the_save_bar_saves_main_settings_before_backup_settings,
+ test_backup_recovery_code_uses_no_html_sinks,
+ test_the_typescript_logic_checks_pass,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ results.append(bool(test()))
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ import traceback
+
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_v2_admin_backup_recovery_logic.ts b/functional_tests/test_v2_admin_backup_recovery_logic.ts
new file mode 100644
index 000000000..d243259bb
--- /dev/null
+++ b/functional_tests/test_v2_admin_backup_recovery_logic.ts
@@ -0,0 +1,731 @@
+// test_v2_admin_backup_recovery_logic.ts
+//
+// Runtime checks for the V2 Admin Settings > Backup & Recovery decisions.
+// Version: 0.261.275
+// Implemented in: 0.261.275
+//
+// The Backup & Recovery cards sit on a server contract that is easy to break without any
+// visible symptom: the settings save sends one whole document, a review fingerprint is
+// checked against the *saved* settings, typed phrases are compared verbatim, and history
+// paging is forward-only. Each decision the browser makes about those lives in
+// `dataManagementLogic.ts` or the store, and is executed here.
+//
+// Run by test_v2_admin_backup_recovery.py, which bundles this with the esbuild Vite brings
+// in and runs it under node, skipping when the front-end toolchain is absent.
+
+import assert from 'node:assert/strict';
+import {
+ COSMOS_EDITOR_SAVE_PHRASE,
+ DM_DEFAULTS,
+ DM_EDITABLE_KEYS,
+ DM_REDACTED,
+ MIRROR_CONFIRMATION_PHRASE,
+ RESTORE_OVERWRITE_PHRASE,
+ migrationManifestUrl,
+ type DataManagementJob,
+ type DmSettings,
+ type MigrationReview,
+ type RestoreReview,
+} from '../application/v2_ui/src/lib/dataManagement';
+import {
+ DM_SECTION_IDS,
+ FIRST_PAGE,
+ buildDmSettingsPayload,
+ buildMigrationPlan,
+ buildReadinessChecklist,
+ buildRestorePlan,
+ canExecuteMigration,
+ canQueueRestore,
+ checkCosmosEdit,
+ computeRetentionDays,
+ dmDirtyKeys,
+ dmSectionStatuses,
+ flattenDetails,
+ formatOperation,
+ furthestOpenStep,
+ humanizeToken,
+ initialMigrationState,
+ initialRestoreDraft,
+ isMigrationReviewCurrent,
+ jobArtifacts,
+ jobWarnings,
+ migrationLiveMetrics,
+ migrationPrincipalCount,
+ migrationReviewKey,
+ migrationStepIssue,
+ normalizeReviewChecks,
+ pagerAfterLoad,
+ pagerBack,
+ pagerForward,
+ pagerPageNumber,
+ progressPercent,
+ readDmValues,
+ restoreDestinationGaps,
+ restoreReviewKey,
+ retentionMaxValue,
+ retryLabel,
+ reviewHeadline,
+ summarizeCosmosChanges,
+ validateCosmosQuery,
+ validateDmValues,
+ validateHistoryDateRange,
+ type MigrationWizardState,
+} from '../application/v2_ui/src/lib/dataManagementLogic';
+import { useDataManagementStore } from '../application/v2_ui/src/stores/dataManagementStore';
+
+const checks: [string, () => void | Promise][] = [];
+function check(name: string, fn: () => void | Promise) {
+ checks.push([name, fn]);
+}
+
+const SAVED: DmSettings = {
+ ...DM_DEFAULTS,
+ backup_storage_blob_endpoint: 'https://backups.blob.core.windows.net',
+ target_cosmos_endpoint: 'https://dest.documents.azure.com:443/',
+ encryption_key_storage: 'key_vault',
+ encryption_key_reference: DM_REDACTED,
+};
+
+function migrationState(overrides: Partial = {}): MigrationWizardState {
+ const state = initialMigrationState();
+ state.scopes.users = {
+ mode: 'selected',
+ selected: [{ id: 'user-1', label: 'Ada' }, { id: 'user-2', label: 'Grace' }],
+ includeDocuments: true,
+ };
+ state.searchWritesFrozen = true;
+ return { ...state, ...overrides };
+}
+
+const READY_MIGRATION_REVIEW: MigrationReview = {
+ ready: true,
+ blocker_count: 0,
+ warning_count: 0,
+ review_fingerprint: 'fingerprint',
+ authorization_token: 'token',
+ authorization_expires_at: '2099-01-01T00:00:00Z',
+ checks: [],
+};
+
+// ---------------------------------------------------------------------------------------
+// Settings
+// ---------------------------------------------------------------------------------------
+
+check('values prefer the unsaved edit, then the saved value, then the default', () => {
+ const values = readDmValues({ retention_value: 45 }, { retention_unit: 'weeks' });
+ assert.equal(values.retention_value, 45);
+ assert.equal(values.retention_unit, 'weeks');
+ assert.equal(values.full_backup_frequency, 'weekly');
+});
+
+check('the save payload carries exactly the editable keys the classic page sends', () => {
+ const payload = buildDmSettingsPayload(readDmValues(SAVED, {}));
+ assert.deepEqual(Object.keys(payload).sort(), [...DM_EDITABLE_KEYS].sort());
+ assert.equal(DM_EDITABLE_KEYS.length, 42);
+ assert.ok(!('encryption_key_reference' in payload), 'the key reference is never sent back');
+});
+
+check('retention days are derived and clamped like the classic page', () => {
+ assert.equal(computeRetentionDays(2, 'weeks'), 14);
+ assert.equal(computeRetentionDays(20, 'years'), 3650);
+ assert.equal(computeRetentionDays(0, 'days'), 1);
+ assert.equal(retentionMaxValue('months'), 121);
+ const payload = buildDmSettingsPayload(readDmValues(SAVED, { retention_value: 3, retention_unit: 'months' }));
+ assert.equal(payload.retention_days, 90);
+});
+
+check('only the credential for the selected sign-in mode is sent', () => {
+ const managed = buildDmSettingsPayload(readDmValues(SAVED, { backup_storage_connection_string: 'secret' }));
+ assert.equal(managed.backup_storage_connection_string, '');
+ assert.equal(managed.backup_storage_blob_endpoint, 'https://backups.blob.core.windows.net');
+
+ const connection = buildDmSettingsPayload(
+ readDmValues(SAVED, { backup_storage_authentication_type: 'connection_string', backup_storage_connection_string: DM_REDACTED }),
+ );
+ assert.equal(connection.backup_storage_blob_endpoint, '');
+ assert.equal(connection.backup_storage_connection_string, DM_REDACTED, 'an untouched secret round-trips as the placeholder');
+
+ const target = buildDmSettingsPayload(
+ readDmValues(SAVED, {
+ target_enhanced_citations_storage_authentication_type: 'connection_string',
+ target_enhanced_citations_storage_blob_endpoint: 'https://ignored.blob.core.windows.net',
+ }),
+ );
+ assert.equal(target.target_enhanced_citations_storage_blob_endpoint, '');
+ assert.equal(target.target_cosmos_database_name, 'SimpleChat');
+});
+
+check('numbers typed as text are sent as numbers', () => {
+ const payload = buildDmSettingsPayload(readDmValues(SAVED, { backup_retry_count: '7', migration_temporary_destination_ru: '4000' }));
+ assert.equal(payload.backup_retry_count, 7);
+ assert.equal(payload.migration_temporary_destination_ru, 4000);
+});
+
+check('an edit typed back to the saved value is not an unsaved change', () => {
+ assert.deepEqual(dmDirtyKeys(SAVED, { backup_retry_count: '5', enabled: false, retention_days: 9 }), []);
+ assert.deepEqual(dmDirtyKeys(SAVED, { backup_retry_count: 6 }), ['backup_retry_count']);
+});
+
+check('clearing a stored secret counts as a change, because saving removes it', () => {
+ const saved = { ...SAVED, target_cosmos_key: DM_REDACTED };
+ assert.deepEqual(dmDirtyKeys(saved, { target_cosmos_key: '' }), ['target_cosmos_key']);
+ assert.deepEqual(dmDirtyKeys(saved, { target_cosmos_key: DM_REDACTED }), []);
+});
+
+check('values the server would clamp are refused before saving', () => {
+ const errors = validateDmValues(
+ readDmValues(SAVED, {
+ scheduled_time_utc: '25:00',
+ retention_value: 200,
+ retention_unit: 'months',
+ backup_temporary_source_ru: 1500,
+ backup_blob_chunk_size_mib: 32,
+ backup_storage_container_name: ' ',
+ }),
+ );
+ assert.ok(errors.scheduled_time_utc);
+ assert.match(errors.retention_value ?? '', /1 to 121/);
+ assert.match(errors.backup_temporary_source_ru ?? '', /steps of 1,000/);
+ assert.ok(errors.backup_blob_chunk_size_mib);
+ assert.ok(errors.backup_storage_container_name);
+ assert.deepEqual(validateDmValues(readDmValues(SAVED, {})), {});
+});
+
+check('card statuses come from the data-management document', () => {
+ assert.deepEqual(dmSectionStatuses(null, {}), {});
+ const statuses = dmSectionStatuses(SAVED, {});
+ assert.equal(statuses[DM_SECTION_IDS.schedule], 'off');
+ assert.equal(statuses[DM_SECTION_IDS.storage], 'ready');
+ assert.equal(statuses[DM_SECTION_IDS.encryption], 'ready');
+ assert.equal(statuses[DM_SECTION_IDS.backup], 'none');
+
+ const unconfigured = dmSectionStatuses({ ...SAVED, backup_storage_blob_endpoint: '', encryption_key_storage: 'not_configured' }, { enabled: true });
+ assert.equal(unconfigured[DM_SECTION_IDS.schedule], 'ready');
+ assert.equal(unconfigured[DM_SECTION_IDS.storage], 'incomplete');
+ assert.equal(unconfigured[DM_SECTION_IDS.encryption], 'incomplete');
+ assert.equal(unconfigured[DM_SECTION_IDS.backup], 'blocked');
+
+ const stored = dmSectionStatuses(
+ { ...SAVED, backup_storage_authentication_type: 'connection_string', backup_storage_connection_string: DM_REDACTED },
+ { encryption_enabled: false },
+ );
+ assert.equal(stored[DM_SECTION_IDS.storage], 'ready', 'a stored, hidden connection string counts as configured');
+ assert.equal(stored[DM_SECTION_IDS.encryption], 'off');
+});
+
+check('the readiness checklist only reports what the server said', () => {
+ const items = buildReadinessChecklist({ ...SAVED, encryption_key_storage: 'settings' }, {}, null);
+ const byId = Object.fromEntries(items.map((item) => [item.id, item]));
+ assert.equal(byId.storage.state, 'ok');
+ assert.equal(byId.encryption.state, 'attention', 'a key in the settings document is worth attention');
+ assert.equal(byId.schedule.state, 'off');
+ assert.match(byId['latest-full'].detail, /not loaded/);
+ assert.equal(byId.destination.state, 'ok');
+ const withBackup = buildReadinessChecklist(SAVED, {}, { latest_full: { id: 'b', completed_at: '2026-01-01T00:00:00Z' } });
+ assert.equal(withBackup.find((item) => item.id === 'latest-full')?.state, 'ok');
+});
+
+// ---------------------------------------------------------------------------------------
+// History lists
+// ---------------------------------------------------------------------------------------
+
+check('previous goes back to the token each earlier page was fetched with', () => {
+ let pager = pagerAfterLoad(FIRST_PAGE, 'token-2');
+ pager = pagerForward(pager);
+ assert.equal(pager.current, 'token-2');
+ pager = pagerForward(pagerAfterLoad(pager, 'token-3'));
+ assert.equal(pagerPageNumber(pager), 3);
+ pager = pagerBack(pager);
+ assert.equal(pager.current, 'token-2');
+ pager = pagerBack(pager);
+ assert.equal(pager.current, null);
+ assert.equal(pagerPageNumber(pager), 1);
+ assert.equal(pagerForward(pager), pager, 'no next token, no move');
+});
+
+check('created-date filters are sent only as a valid pair within a year', () => {
+ assert.equal(validateHistoryDateRange('', ''), null);
+ assert.match(validateHistoryDateRange('2026-01-01', '') ?? '', /both/);
+ assert.match(validateHistoryDateRange('2026-02-01', '2026-01-01') ?? '', /on or after/);
+ assert.equal(validateHistoryDateRange('2026-01-01', '2027-01-01'), null, '366 days inclusive is allowed');
+ assert.match(validateHistoryDateRange('2026-01-01', '2027-01-02') ?? '', /366/);
+});
+
+// ---------------------------------------------------------------------------------------
+// Review evidence
+// ---------------------------------------------------------------------------------------
+
+check('restore and migration checks read the same way', () => {
+ const checks = normalizeReviewChecks([
+ { id: 'a', label: 'Restore', status: 'warn', message: 'Partial backup' },
+ { id: 'b', label: 'Migration', status: 'warning', summary: 'Deletes', workflow_step: 'options' },
+ { id: 'c', label: 'Odd', status: 'mystery' },
+ { id: 'd', label: 'Fine', status: 'pass', message: 'ok' },
+ ]);
+ assert.deepEqual(checks.map((item) => item.tone), ['warning', 'warning', 'block', 'pass']);
+ assert.equal(checks[1].message, 'Deletes');
+ assert.equal(checks[1].workflowStep, 'options');
+ assert.equal(checks[2].message, 'No details were returned.');
+});
+
+check('the review headline says ready, warnings, blockers or out of date', () => {
+ assert.equal(reviewHeadline(null, false).tone, 'none');
+ assert.equal(reviewHeadline({ ready: true }, true).tone, 'stale');
+ assert.equal(reviewHeadline({ ready: true, warning_count: 0 }, false).text, 'Ready');
+ assert.equal(reviewHeadline({ ready: true, warning_count: 2 }, false).text, 'Ready with 2 warnings');
+ assert.equal(reviewHeadline({ ready: false, blocker_count: 1 }, false).text, '1 blocker');
+});
+
+// ---------------------------------------------------------------------------------------
+// Restore
+// ---------------------------------------------------------------------------------------
+
+check('the restore plan carries the overwrite phrase only for an overwrite', () => {
+ const createOnly = buildRestorePlan('backup-1', { ...initialRestoreDraft(), overwritePhrase: 'x' });
+ assert.equal(createOnly.restore_policy, 'create_only');
+ assert.equal(createOnly.overwrite_confirmed, false);
+ assert.equal(createOnly.overwrite_confirmation_phrase, '');
+ const overwrite = buildRestorePlan('backup-1', {
+ ...initialRestoreDraft(),
+ policy: 'overwrite_existing',
+ overwritePhrase: RESTORE_OVERWRITE_PHRASE,
+ });
+ assert.equal(overwrite.overwrite_confirmed, true);
+ assert.equal(overwrite.overwrite_confirmation_phrase, RESTORE_OVERWRITE_PHRASE);
+});
+
+check('typing the phrase or acknowledging does not make a restore review stale', () => {
+ const values = readDmValues(SAVED, {});
+ const draft = { ...initialRestoreDraft(), policy: 'overwrite_existing' as const };
+ const key = restoreReviewKey('backup-1', draft, values);
+ assert.equal(restoreReviewKey('backup-1', { ...draft, overwritePhrase: RESTORE_OVERWRITE_PHRASE, acknowledged: true }, values), key);
+ assert.notEqual(restoreReviewKey('backup-1', { ...draft, includeAiSearch: false }, values), key);
+ assert.notEqual(
+ restoreReviewKey('backup-1', draft, readDmValues(SAVED, { target_cosmos_endpoint: 'https://other.documents.azure.com' })),
+ key,
+ 'a different destination is a different review',
+ );
+});
+
+check('a restore queues only with a current, ready, unexpired review and every confirmation', () => {
+ const review: RestoreReview = { ready: true, authorization_token: 'token' };
+ const draft = { ...initialRestoreDraft(), acknowledged: true };
+ const ready = { review, reviewCurrent: true, draft, expiresInSeconds: 120, busy: false };
+ assert.equal(canQueueRestore(ready), true);
+ assert.equal(canQueueRestore({ ...ready, reviewCurrent: false }), false);
+ assert.equal(canQueueRestore({ ...ready, expiresInSeconds: 0 }), false);
+ assert.equal(canQueueRestore({ ...ready, draft: { ...draft, acknowledged: false } }), false);
+ assert.equal(canQueueRestore({ ...ready, review: { ready: true } }), false, 'no authorization, no queue');
+ assert.equal(canQueueRestore({ ...ready, draft: { ...draft, policy: 'overwrite_existing', overwritePhrase: 'restore with overwrite' } }), false);
+ assert.equal(canQueueRestore({ ...ready, draft: { ...draft, policy: 'overwrite_existing', overwritePhrase: RESTORE_OVERWRITE_PHRASE } }), true);
+});
+
+check('restore destination gaps only include selected restore surfaces', () => {
+ const allSelected = initialRestoreDraft();
+ const empty = readDmValues(SAVED, {
+ target_cosmos_endpoint: '',
+ target_ai_search_endpoint: '',
+ target_enhanced_citations_storage_blob_endpoint: '',
+ });
+ assert.deepEqual(
+ restoreDestinationGaps(allSelected, empty).map((gap) => gap.label),
+ ['Cosmos DB', 'AI Search', 'Enhanced Citation files'],
+ );
+ assert.deepEqual(
+ restoreDestinationGaps({ ...allSelected, includeCosmos: false, includeSourceBlobs: false }, empty).map((gap) => gap.label),
+ ['AI Search'],
+ );
+ assert.deepEqual(
+ restoreDestinationGaps(
+ { ...allSelected, includeCosmos: false, includeAiSearch: true, includeSourceBlobs: false },
+ readDmValues(SAVED, {
+ target_cosmos_endpoint: '',
+ target_ai_search_endpoint: 'https://destination.search.windows.net',
+ }),
+ ),
+ [],
+ 'an AI Search-only restore does not require a Cosmos DB endpoint',
+ );
+});
+
+check('restore destination storage accepts the credential for the selected target storage auth', () => {
+ const draft = { ...initialRestoreDraft(), includeCosmos: false, includeAiSearch: false };
+ const connectionStringValues = readDmValues(SAVED, {
+ target_enhanced_citations_storage_authentication_type: 'connection_string',
+ target_enhanced_citations_storage_blob_endpoint: '',
+ target_enhanced_citations_storage_connection_string: DM_REDACTED,
+ });
+ assert.deepEqual(restoreDestinationGaps(draft, connectionStringValues), []);
+ const missingConnectionString = readDmValues(SAVED, {
+ target_enhanced_citations_storage_authentication_type: 'connection_string',
+ target_enhanced_citations_storage_blob_endpoint: 'https://ignored.blob.core.windows.net',
+ target_enhanced_citations_storage_connection_string: '',
+ });
+ assert.deepEqual(
+ restoreDestinationGaps(draft, missingConnectionString).map((gap) => gap.label),
+ ['Enhanced Citation files'],
+ );
+});
+
+// ---------------------------------------------------------------------------------------
+// Migration
+// ---------------------------------------------------------------------------------------
+
+check('the migration plan is built as the classic page builds it', () => {
+ const state = migrationState();
+ state.scopes.groups = { mode: 'none', selected: [{ id: 'g' }], includeDocuments: true };
+ state.scopes.public_workspaces = { mode: 'all', selected: [{ id: 'p' }], includeDocuments: true };
+ state.baselineJobId = '1b4e28ba-2fa1-11d2-883f-0016d3cca427';
+ state.mirrorPhrase = 'anything';
+ const plan = buildMigrationPlan(state);
+ assert.deepEqual(plan.users, { mode: 'selected', ids: ['user-1', 'user-2'], include_documents: true });
+ assert.deepEqual(plan.groups, { mode: 'none', ids: [], include_documents: false });
+ assert.deepEqual(plan.public_workspaces, { mode: 'all', ids: [], include_documents: true });
+ assert.equal(plan.baseline_job_id, '', 'new_only ignores a baseline');
+ assert.equal(plan.mirror_confirmation, '', 'only mirror mode sends the phrase');
+ assert.equal(buildMigrationPlan({ ...state, includeAiSearch: false }).target_ai_search_writes_frozen, false);
+ const mirror = buildMigrationPlan({ ...state, mode: 'mirror_with_deletions', mirrorPhrase: MIRROR_CONFIRMATION_PHRASE });
+ assert.equal(mirror.mirror_confirmation, MIRROR_CONFIRMATION_PHRASE);
+ assert.equal(mirror.baseline_job_id, '1b4e28ba-2fa1-11d2-883f-0016d3cca427');
+});
+
+check('migration principal counts use selected ids and server totals for All', () => {
+ const state = migrationState();
+ state.scopes.groups = { mode: 'all', selected: [], includeDocuments: true, serverCount: 8, serverCountStatus: 'ready' };
+ state.scopes.public_workspaces = { mode: 'none', selected: [{ id: 'ignored' }], includeDocuments: true };
+ assert.deepEqual(migrationPrincipalCount(state), { total: 10, includedKnown: true, includedTypeCount: 2 });
+
+ const loading = migrationState();
+ loading.scopes.users = { mode: 'all', selected: [], includeDocuments: false, serverCount: null, serverCountStatus: 'loading' };
+ assert.deepEqual(migrationPrincipalCount(loading), { total: 0, includedKnown: false, includedTypeCount: 1 });
+});
+
+check('a known empty All scope is blocked with the server review reason', () => {
+ const values = readDmValues(SAVED, {});
+ const state = initialMigrationState();
+ state.scopes.users = { mode: 'all', selected: [], includeDocuments: true, serverCount: 0, serverCountStatus: 'ready' };
+ assert.equal(
+ migrationStepIssue('scope', state, values),
+ 'Choose at least one user, group, or public workspace before review.',
+ );
+});
+
+check('the mirror phrase does not stale a review, but destination and scope edits do', () => {
+ const values = readDmValues(SAVED, {});
+ const state = migrationState({ mode: 'mirror_with_deletions' });
+ const key = migrationReviewKey(state, values);
+ assert.equal(migrationReviewKey({ ...state, mirrorPhrase: MIRROR_CONFIRMATION_PHRASE, acknowledged: true }, values), key);
+ assert.notEqual(migrationReviewKey(state, readDmValues(SAVED, { target_cosmos_endpoint: 'https://x.documents.azure.com' })), key);
+ assert.notEqual(migrationReviewKey(state, readDmValues(SAVED, { migration_retry_count: 9 })), key);
+ assert.notEqual(migrationReviewKey({ ...state, includeAiSearch: false }, values), key);
+ const reviewed = { ...state, review: READY_MIGRATION_REVIEW, reviewKey: key };
+ assert.equal(isMigrationReviewCurrent(reviewed, values), true);
+ assert.equal(isMigrationReviewCurrent(reviewed, readDmValues(SAVED, { target_ai_search_key: 'new' })), false);
+});
+
+check('each wizard step says why it cannot be left', () => {
+ const empty = readDmValues({ ...SAVED, target_cosmos_endpoint: '' }, {});
+ const values = readDmValues(SAVED, {});
+ assert.match(migrationStepIssue('target', migrationState(), empty) ?? '', /endpoint/);
+ assert.equal(migrationStepIssue('target', migrationState(), values), null);
+ assert.match(migrationStepIssue('scope', initialMigrationState(), values) ?? '', /at least one/);
+ assert.match(migrationStepIssue('options', migrationState({ searchWritesFrozen: false }), values) ?? '', /frozen/);
+ assert.equal(migrationStepIssue('options', migrationState({ includeAiSearch: false, searchWritesFrozen: false }), values), null);
+ assert.match(migrationStepIssue('options', migrationState({ mode: 'delta_upsert', baselineJobId: 'not-a-guid' }), values) ?? '', /GUID/);
+ assert.match(
+ migrationStepIssue('options', migrationState(), readDmValues(SAVED, { migration_temporary_destination_ru_enabled: true })) ?? '',
+ /subscription ID and resource group/,
+ );
+ assert.match(migrationStepIssue('review', migrationState(), values) ?? '', /Run the preflight/);
+ const state = migrationState();
+ const blocked = { ...state, review: { ...READY_MIGRATION_REVIEW, ready: false }, reviewKey: migrationReviewKey(state, values) };
+ assert.match(migrationStepIssue('review', blocked, values) ?? '', /blockers/);
+ assert.equal(furthestOpenStep(initialMigrationState(), values), 1);
+ assert.equal(furthestOpenStep(migrationState(), values), 3);
+});
+
+check('a migration starts only with a current authorized review and every confirmation', () => {
+ const values = readDmValues(SAVED, {});
+ const now = Date.parse('2026-06-01T00:00:00Z');
+ const base = migrationState({ acknowledged: true });
+ const ready = { ...base, review: READY_MIGRATION_REVIEW, reviewKey: migrationReviewKey(base, values) };
+ assert.equal(canExecuteMigration(ready, values, now), true);
+ assert.equal(canExecuteMigration({ ...ready, acknowledged: false }, values, now), false);
+ assert.equal(canExecuteMigration({ ...ready, submission: 'submitting' }, values, now), false);
+ assert.equal(
+ canExecuteMigration({ ...ready, review: { ...READY_MIGRATION_REVIEW, authorization_expires_at: '2020-01-01T00:00:00Z' } }, values, now),
+ false,
+ );
+ const mirror = { ...ready, mode: 'mirror_with_deletions' as const };
+ const mirrorReady = { ...mirror, reviewKey: migrationReviewKey(mirror, values) };
+ assert.equal(canExecuteMigration(mirrorReady, values, now), false, 'mirror needs the phrase');
+ assert.equal(canExecuteMigration({ ...mirrorReady, mirrorPhrase: MIRROR_CONFIRMATION_PHRASE }, values, now), true);
+});
+
+// ---------------------------------------------------------------------------------------
+// Jobs
+// ---------------------------------------------------------------------------------------
+
+check('job labels and progress read like the classic page', () => {
+ assert.equal(progressPercent({ percent_complete: '140' }), 100);
+ assert.equal(progressPercent({ percent_complete: 'x' }), 0);
+ assert.equal(humanizeToken('completed_with_warnings'), 'Completed with warnings');
+ assert.equal(humanizeToken('mirror_with_deletions'), 'Make destination match source');
+ assert.equal(formatOperation('backup', 'partial'), 'Partial backup');
+ assert.equal(formatOperation('dry_run'), 'Dry run');
+ assert.equal(retryLabel({ id: '1', status: 'running', operation: 'migration' }), 'Resume');
+ assert.equal(retryLabel({ id: '1', status: 'failed', operation: 'backup' }), 'Retry failures');
+ assert.equal(retryLabel({ id: '1', status: 'failed', operation: 'restore' }), 'Retry');
+});
+
+check('live migration metrics include liveness only while the job runs', () => {
+ const now = Date.parse('2026-06-01T00:00:30Z');
+ const job: DataManagementJob = {
+ id: 'job',
+ operation: 'migration',
+ status: 'running',
+ last_heartbeat_at: '2026-06-01T00:00:25Z',
+ last_progress_at: '2026-06-01T00:00:00Z',
+ migration_state: {
+ totals: { processed_count: 1200, bytes: 2048, copied_count: 1000, skipped_count: 200 },
+ resources: { a: { status: 'in_progress', progress: { items_per_second: 12 } } },
+ capacity: { status: 'boosted' },
+ },
+ };
+ const metrics = Object.fromEntries(migrationLiveMetrics(job, now).map((metric) => [metric.label, metric.value]));
+ assert.equal(metrics.Processed, (1200).toLocaleString());
+ assert.equal(metrics.Transferred, '2.0 KB');
+ assert.equal(metrics['Items per second'], '12');
+ assert.equal(metrics['Destination capacity'], 'Boosted');
+ assert.equal(metrics.Liveness, 'Running, alive with no recent progress');
+ assert.ok(!migrationLiveMetrics({ ...job, status: 'completed' }, now).some((metric) => metric.label === 'Liveness'));
+});
+
+check('artifacts and warnings come from the result, falling back to the timeline', () => {
+ const job: DataManagementJob = { id: 'j', warnings: ['Job warning'], result: {} };
+ const items = [{ details: { artifacts: [{ name: 'users', warning: 'Throttled' }] } }];
+ const artifacts = jobArtifacts(job, items);
+ assert.equal(artifacts.length, 1);
+ assert.deepEqual(jobWarnings(job, artifacts), ['Job warning', 'users: Throttled']);
+ const details = flattenDetails({ counts: { created: 2, skipped: 0 }, ids: ['a', 'b'], flag: true, empty: '' });
+ assert.deepEqual(details.map((item) => item.label), ['Counts created', 'Counts skipped', 'Ids', 'Flag']);
+ assert.equal(details.find((item) => item.label === 'Flag')?.value, 'Yes');
+});
+
+check('manifest links are same-origin with an encoded job id', () => {
+ assert.equal(
+ migrationManifestUrl('a/b c'),
+ '/api/admin/data-management/jobs/a%2Fb%20c/migration-manifest',
+ );
+ assert.equal(
+ migrationManifestUrl('job', true),
+ '/api/admin/data-management/jobs/job/migration-manifest?statuses=failed%2Cmissing%2Ccollision',
+ );
+});
+
+// ---------------------------------------------------------------------------------------
+// Cosmos editor
+// ---------------------------------------------------------------------------------------
+
+check('queries are checked the way the server checks them', () => {
+ assert.equal(validateCosmosQuery(''), null, 'an empty query browses');
+ assert.equal(validateCosmosQuery('select * from c'), null);
+ assert.match(validateCosmosQuery('SELECT * FROM c; DELETE') ?? '', /semicolons/);
+ assert.match(validateCosmosQuery('DELETE FROM c') ?? '', /SELECT/);
+ assert.match(validateCosmosQuery(`SELECT ${'x'.repeat(4000)}`) ?? '', /4,000/);
+ assert.equal(COSMOS_EDITOR_SAVE_PHRASE, 'I understand this can damage system data');
+});
+
+check('an edit cannot change the id or the partition key', () => {
+ const original = { id: 'doc-1', partitionKey: 'tenant-a', partitionKeyPath: '/owner/tenant' };
+ assert.equal(checkCosmosEdit('{', original).ok, false);
+ assert.equal(checkCosmosEdit('[]', original).ok, false);
+ const idChanged = checkCosmosEdit('{"id":"doc-2","owner":{"tenant":"tenant-a"}}', original);
+ assert.equal(idChanged.ok, false);
+ const keyChanged = checkCosmosEdit('{"id":"doc-1","owner":{"tenant":"tenant-b"}}', original);
+ assert.equal(keyChanged.ok, false);
+ assert.match(keyChanged.ok ? '' : keyChanged.error, /partition key/);
+ assert.equal(checkCosmosEdit('{"id":"doc-1","owner":{"tenant":"tenant-a"},"x":1}', original).ok, true);
+});
+
+check('the change summary skips system fields', () => {
+ const summary = summarizeCosmosChanges(
+ { id: '1', a: 1, b: { c: 1 }, gone: true, _etag: 'x' },
+ { id: '1', a: 2, b: { c: 1, d: 2 }, _etag: 'y' },
+ );
+ assert.deepEqual(summary.changedPaths.sort(), ['a', 'b.d', 'gone']);
+ assert.equal(summary.addedCount, 1);
+ assert.equal(summary.removedCount, 1);
+ assert.equal(summary.updatedCount, 1);
+});
+
+// ---------------------------------------------------------------------------------------
+// Store: save, save-first and lifetime
+// ---------------------------------------------------------------------------------------
+
+interface Captured {
+ method: string;
+ url: string;
+ body: unknown;
+}
+
+function mockFetch(handler: (request: Captured) => { status?: number; body: unknown } | Promise<{ status?: number; body: unknown }>) {
+ const requests: Captured[] = [];
+ globalThis.fetch = (async (input: unknown, init?: { method?: string; body?: string }) => {
+ const request = { method: init?.method ?? 'GET', url: String(input), body: init?.body ? JSON.parse(init.body) : undefined };
+ requests.push(request);
+ const { status = 200, body } = await handler(request);
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ }) as typeof fetch;
+ return requests;
+}
+
+const store = useDataManagementStore;
+
+function primeStore(draft = {}, mainDirtyKeys: string[] = []) {
+ store.getState().reset();
+ store.setState({ status: 'ready', settings: { ...SAVED }, draft, mainDirtyKeys });
+}
+
+check('a save sends the whole editable document and rebases on the response', async () => {
+ primeStore({ backup_retry_count: 7 });
+ const requests = mockFetch((request) => ({ body: { success: true, settings: { ...SAVED, ...(request.body as object), backup_retry_count: 7 } } }));
+ const outcome = await store.getState().save();
+ assert.equal(outcome.ok, true);
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].method, 'PUT');
+ assert.equal(requests[0].url, '/api/admin/data-management/settings');
+ assert.deepEqual(Object.keys(requests[0].body as object).sort(), [...DM_EDITABLE_KEYS].sort());
+ assert.equal(store.getState().settings?.backup_retry_count, 7);
+ assert.deepEqual(store.getState().draft, {});
+});
+
+check('an edit made while saving survives the save', async () => {
+ primeStore({ backup_retry_count: 7 });
+ let release: () => void = () => undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ mockFetch(async () => {
+ await gate;
+ return { body: { settings: { ...SAVED, backup_retry_count: 7 } } };
+ });
+ const saving = store.getState().save();
+ store.getState().setValue('migration_retry_count', 9);
+ release();
+ await saving;
+ assert.deepEqual(store.getState().draft, { migration_retry_count: 9 });
+});
+
+check('an invalid value stops the save before any request', async () => {
+ primeStore({ backup_retry_count: 99 });
+ const requests = mockFetch(() => ({ body: {} }));
+ const outcome = await store.getState().save();
+ assert.equal(outcome.ok, false);
+ assert.equal(outcome.invalid, true);
+ assert.equal(requests.length, 0);
+ assert.ok(store.getState().fieldErrors.backup_retry_count);
+});
+
+check('a save-first action saves pending backup settings, then proceeds', async () => {
+ primeStore({ backup_retry_count: 6 });
+ const requests = mockFetch(() => ({ body: { settings: { ...SAVED, backup_retry_count: 6 } } }));
+ const ready = await store.getState().ensureReadyFor('queue-backup');
+ assert.deepEqual(ready, { ok: true });
+ assert.equal(requests.length, 1);
+ assert.deepEqual(store.getState().draft, {});
+});
+
+check('nothing is saved when there is nothing to save', async () => {
+ primeStore({});
+ const requests = mockFetch(() => ({ body: {} }));
+ assert.deepEqual(await store.getState().ensureReadyFor('retention-cleanup'), { ok: true });
+ assert.equal(requests.length, 0);
+});
+
+check('pending Enhanced Citations edits require saving everything first', async () => {
+ primeStore({ backup_retry_count: 6 }, ['office_docs_storage_account_url', 'app_title']);
+ const requests = mockFetch(() => ({ body: {} }));
+ const ready = await store.getState().ensureReadyFor('restore-review');
+ assert.equal(ready.ok, false);
+ assert.equal(ready.ok ? '' : ready.reason, 'save-all-required');
+ assert.equal(requests.length, 0);
+
+ primeStore({}, ['office_docs_storage_account_url']);
+ assert.deepEqual(await store.getState().ensureReadyFor('queue-backup'), { ok: true }, 'no backup save, nothing to validate');
+});
+
+check('key generation waits for pending Key Vault edits but saves nothing', async () => {
+ primeStore({ backup_retry_count: 6 }, ['key_vault_name']);
+ const requests = mockFetch(() => ({ body: {} }));
+ const ready = await store.getState().ensureReadyFor('generate-key');
+ assert.equal(ready.ok ? '' : ready.reason, 'save-all-required');
+ primeStore({ backup_retry_count: 6 }, []);
+ assert.deepEqual(await store.getState().ensureReadyFor('generate-key'), { ok: true });
+ assert.equal(requests.length, 0, 'generating a key never saves the backup draft');
+});
+
+check('a save that finishes after the page is left writes nothing', async () => {
+ primeStore({ backup_retry_count: 7 });
+ let release: () => void = () => undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ mockFetch(async () => {
+ await gate;
+ return { body: { settings: { ...SAVED, backup_retry_count: 7 } } };
+ });
+ const saving = store.getState().save();
+ store.getState().reset();
+ release();
+ await saving;
+ assert.equal(store.getState().settings, null);
+ assert.equal(store.getState().saving, false);
+});
+
+check('tracked requests are counted, and a reset never leaves a negative count', async () => {
+ primeStore();
+ let release: () => void = () => undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const tracked = store.getState().trackRequest(gate);
+ assert.equal(store.getState().pendingRequests, 1);
+ store.getState().reset();
+ release();
+ await tracked;
+ assert.equal(store.getState().pendingRequests, 0);
+});
+
+check('the Cosmos editor is dirty only when its JSON differs from what was opened', async () => {
+ const { selectCosmosEditorDirty } = await import('../application/v2_ui/src/stores/dataManagementStore');
+ primeStore();
+ const original = { id: '1', value: 1 };
+ store.getState().updateCosmos({
+ document: {
+ container: 'c', id: '1', partitionKey: '1', partitionKeyPath: '/id', etag: 'e', original,
+ text: JSON.stringify(original, null, 2), editable: true,
+ },
+ });
+ assert.equal(selectCosmosEditorDirty(store.getState()), false);
+ store.getState().updateCosmos((session) => ({ ...session, document: session.document && { ...session.document, text: '{}' } }));
+ assert.equal(selectCosmosEditorDirty(store.getState()), true);
+ store.getState().lockCosmos();
+ assert.equal(selectCosmosEditorDirty(store.getState()), false);
+});
+
+let failures = 0;
+for (const [name, fn] of checks) {
+ try {
+ await fn();
+ console.log(` ok ${name}`);
+ } catch (error) {
+ failures += 1;
+ console.log(` FAIL ${name}`);
+ console.log(error);
+ }
+}
+console.log(`\n${checks.length - failures}/${checks.length} backup and recovery logic checks passed`);
+if (failures) {
+ process.exit(1);
+}
diff --git a/functional_tests/test_v2_admin_backup_recovery_parity.py b/functional_tests/test_v2_admin_backup_recovery_parity.py
new file mode 100644
index 000000000..2843d5519
--- /dev/null
+++ b/functional_tests/test_v2_admin_backup_recovery_parity.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+# test_v2_admin_backup_recovery_parity.py
+"""
+Functional parity test between the classic and V2 Backup & Recovery admin surfaces.
+Version: 0.261.275
+Implemented in: 0.261.275
+
+Both interfaces drive the same admin API under /api/admin/data-management. The classic
+page is the reference: what it saves, which endpoints it calls, which typed phrases it
+sends. The V2 surface re-describes all of that in TypeScript, and every one of those
+descriptions can drift silently -- a missing settings key is simply never saved, a
+missing endpoint is a capability that quietly does not exist in V2, and a phrase that
+differs by one character makes a destructive action impossible to confirm.
+
+These checks read both sides from source and require them to agree.
+"""
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import import_app_module
+from test_support.nav import ADMIN_NAV
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_ROOT = REPO_ROOT / "application" / "single_app"
+V1_JS = APP_ROOT / "static" / "js" / "admin" / "admin_data_management.js"
+BACKEND = APP_ROOT / "functions_data_management.py"
+V2_LIB = REPO_ROOT / "application" / "v2_ui" / "src" / "lib" / "dataManagement.ts"
+V2_FIELDS = REPO_ROOT / "application" / "v2_ui" / "src" / "lib" / "dataManagementFields.ts"
+
+# The Backup & Recovery navigation, tab by tab, and the V2 component drawing each section.
+EXPECTED_SECTIONS = {
+ "backup": {
+ "data-management-readiness-section": "data-management-readiness",
+ "data-management-backup-section": "data-management-backup-runs",
+ "data-management-schedule-section": "data-management-schedule",
+ "data-management-storage-section": "data-management-storage",
+ "data-management-encryption-section": "data-management-encryption",
+ },
+ "migrate": {"data-management-migration-section": "data-management-migration"},
+ "restore": {"data-management-backup-inventory-section": "data-management-backup-inventory"},
+ "cosmos-editor": {"data-management-cosmos-editor-section": "data-management-cosmos-editor"},
+ "jobs": {"data-management-jobs-section": "data-management-jobs"},
+}
+
+# Typed confirmations: V2 constant -> backend constant.
+PHRASES = {
+ "RESTORE_OVERWRITE_PHRASE": "DATA_MANAGEMENT_RESTORE_OVERWRITE_CONFIRMATION",
+ "MIRROR_CONFIRMATION_PHRASE": "DATA_MANAGEMENT_MIRROR_CONFIRMATION",
+ "COSMOS_EDITOR_SAVE_PHRASE": "DATA_MANAGEMENT_COSMOS_EDITOR_CONFIRMATION_PHRASE",
+}
+
+# Upper bounds the V2 form enforces -> the backend constant that clamps the same value.
+UPPER_BOUNDS = {
+ "backup_max_parallel_operations": "DATA_MANAGEMENT_BACKUP_MAX_PARALLEL_OPERATIONS",
+ "backup_retry_count": "DATA_MANAGEMENT_BACKUP_MAX_RETRY_COUNT",
+ "backup_blob_max_parallel_operations": "DATA_MANAGEMENT_BLOB_BACKUP_MAX_PARALLEL_OPERATIONS",
+ "backup_blob_chunk_size_mib": "DATA_MANAGEMENT_BLOB_BACKUP_MAX_CHUNK_SIZE_MIB",
+ "backup_blob_retry_count": "DATA_MANAGEMENT_BACKUP_MAX_RETRY_COUNT",
+ "backup_temporary_source_ru": "DATA_MANAGEMENT_BACKUP_MAX_SOURCE_RU",
+ "migration_max_parallel_operations": "DATA_MANAGEMENT_MIGRATION_MAX_PARALLEL_OPERATIONS",
+ "migration_retry_count": "DATA_MANAGEMENT_MIGRATION_MAX_RETRY_COUNT",
+ "migration_skip_recent_within_hours": "DATA_MANAGEMENT_MIGRATION_MAX_SKIP_WITHIN_HOURS",
+ "migration_temporary_destination_ru": "DATA_MANAGEMENT_MIGRATION_MAX_DESTINATION_RU",
+}
+
+fields_module = import_app_module("admin_settings_fields")
+
+
+def _read(path):
+ assert path.is_file(), f"Missing expected file: {path}"
+ return path.read_text(encoding="utf-8")
+
+
+def _block(source, start_marker, open_char="{", close_char="}"):
+ """Return the text between the bracket opened after ``start_marker`` and its match."""
+ start = source.index(start_marker)
+ open_index = source.index(open_char, start)
+ depth = 0
+ for index in range(open_index, len(source)):
+ if source[index] == open_char:
+ depth += 1
+ elif source[index] == close_char:
+ depth -= 1
+ if depth == 0:
+ return source[open_index + 1 : index]
+ raise AssertionError(f"Unbalanced block after {start_marker!r}")
+
+
+def _backend_constant(source, name):
+ match = re.search(rf"^{name} = (.+)$", source, re.MULTILINE)
+ assert match, f"Backend constant {name} was not found"
+ return ast.literal_eval(match.group(1).strip())
+
+
+def _assigned_object(source, marker):
+ """Return the body of the object literal assigned after ``marker``.
+
+ A TypeScript annotation can hold braces of its own, so the search starts at the
+ assignment rather than at the first brace after the name.
+ """
+ assignment = source.index("= {", source.index(marker))
+ return _block(source[assignment:], "= {")
+
+
+def _v1_collect_settings_keys():
+ body = _block(_read(V1_JS), "function collectSettings()")
+ returned = _block(body, "return {")
+ keys = re.findall(r"^\s*([a-z0-9_]+):", returned, re.MULTILINE)
+ assert keys, "Could not read collectSettings() from admin_data_management.js"
+ return keys
+
+
+def _v2_editable_keys():
+ block = _block(_read(V2_LIB), "export const DM_EDITABLE_KEYS = [", "[", "]")
+ keys = re.findall(r"'([a-z0-9_]+)'", block)
+ assert keys, "Could not read DM_EDITABLE_KEYS from dataManagement.ts"
+ return keys
+
+
+def _normalize_endpoint(path):
+ path = path.split("?", 1)[0]
+ path = re.sub(r"\$\{query\}$", "", path)
+ path = re.sub(r"\$\{[^}]+\}", "{}", path)
+ return path.rstrip("/")
+
+
+def test_backup_recovery_navigation_is_fully_described():
+ """Every Backup & Recovery section is drawn by exactly one V2 component."""
+ print("Testing Backup & Recovery navigation against the V2 components...")
+
+ assert_app_version_at_least("0.261.275")
+
+ group = next(item for item in ADMIN_NAV if item["id"] == "backup-recovery")
+ tabs = {tab["id"]: [section["id"] for section in tab["sections"]] for tab in group["tabs"]}
+ assert set(tabs) == set(EXPECTED_SECTIONS), (
+ "The Backup & Recovery tabs changed. Update EXPECTED_SECTIONS and the V2 "
+ f"components together.\n ADMIN_NAV: {sorted(tabs)}"
+ )
+
+ schema = fields_module.get_admin_settings_fields()
+ problems = []
+ for tab_id, sections in EXPECTED_SECTIONS.items():
+ if list(sections) != tabs[tab_id]:
+ problems.append(f"{tab_id}: nav sections {tabs[tab_id]} != {list(sections)}")
+ for section_id, component in sections.items():
+ fields = schema.get(section_id, [])
+ components = [field.get("component") for field in fields if field.get("type") == "component"]
+ if components != [component] or len(fields) != 1:
+ problems.append(f"{section_id}: expected only component {component!r}, found {fields}")
+ assert not problems, "Backup & Recovery is not fully described:\n " + "\n ".join(problems)
+
+ print(f" {sum(len(sections) for sections in EXPECTED_SECTIONS.values())} section(s) across {len(tabs)} tab(s) described.")
+ return True
+
+
+def test_v2_saves_every_setting_the_classic_page_saves():
+ """A key the classic page sends but V2 does not would never be saved from V2."""
+ print("\nTesting the settings save payload against collectSettings()...")
+
+ v1_keys = _v1_collect_settings_keys()
+ v2_keys = _v2_editable_keys()
+ assert v1_keys == v2_keys, (
+ "DM_EDITABLE_KEYS must match the classic collectSettings() keys, in order.\n"
+ f" only classic: {sorted(set(v1_keys) - set(v2_keys))}\n"
+ f" only V2: {sorted(set(v2_keys) - set(v1_keys))}"
+ )
+
+ fields_source = _read(V2_FIELDS)
+ derived = {"retention_days", "target_cosmos_database_name"}
+ # Edited by bespoke controls in the Schedule card rather than a declared field.
+ custom_controls = {"scheduled_time_utc", "retention_value", "retention_unit"}
+ undeclared = [
+ key for key in v2_keys
+ if key not in derived and key not in custom_controls and f"dmKey: '{key}'" not in fields_source
+ ]
+ assert not undeclared, (
+ "These saved settings have no control declaration in dataManagementFields.ts, so "
+ f"nothing in V2 can edit them: {undeclared}"
+ )
+
+ print(f" All {len(v1_keys)} settings keys match, and every editable one has a control.")
+ return True
+
+
+def test_v2_calls_every_endpoint_the_classic_page_calls():
+ """An endpoint only the classic page calls is a capability V2 lacks."""
+ print("\nTesting data-management endpoints...")
+
+ v1_paths = set()
+ for raw in re.findall(r"/api/admin/data-management([^\"'`]*)", _read(V1_JS)):
+ if raw.startswith("/${listKind}"):
+ # One helper serves both history lists.
+ v1_paths.update({"/backups", "/jobs"})
+ continue
+ v1_paths.add(_normalize_endpoint(raw))
+
+ v2_source = _read(V2_LIB)
+ v2_paths = {
+ _normalize_endpoint(raw)
+ for raw in re.findall(r"`\$\{DM_API\}([^`]*)`", v2_source)
+ }
+
+ missing = sorted(v1_paths - v2_paths)
+ assert not missing, (
+ "The classic page calls these data-management endpoints but dataManagement.ts "
+ f"does not, so V2 is missing what they do: {missing}"
+ )
+
+ print(f" All {len(v1_paths)} classic endpoint(s) are called by V2.")
+ return True
+
+
+def test_typed_confirmations_match_the_server():
+ """A phrase that differs by one character makes the action impossible to confirm."""
+ print("\nTesting typed confirmation phrases...")
+
+ backend = _read(BACKEND)
+ v2_source = _read(V2_LIB)
+ for v2_name, backend_name in PHRASES.items():
+ match = re.search(rf"export const {v2_name} = '([^']+)';", v2_source)
+ assert match, f"{v2_name} is missing from dataManagement.ts"
+ expected = _backend_constant(backend, backend_name)
+ assert match.group(1) == expected, f"{v2_name} is {match.group(1)!r}, the server expects {expected!r}"
+
+ print(f" All {len(PHRASES)} phrases match the server.")
+ return True
+
+
+def test_defaults_and_bounds_match_the_server():
+ """A drifted default shows the wrong value for a setting the document does not hold yet."""
+ print("\nTesting data-management defaults and bounds...")
+
+ backend = _read(BACKEND)
+ defaults_block = _block(backend, "DATA_MANAGEMENT_DEFAULT_SETTINGS = {")
+ backend_defaults = {}
+ for key, raw in re.findall(r'^\s*"([a-z0-9_]+)":\s*(.+?),\s*$', defaults_block, re.MULTILINE):
+ raw = raw.strip()
+ try:
+ backend_defaults[key] = ast.literal_eval(raw)
+ except (ValueError, SyntaxError):
+ backend_defaults[key] = _backend_constant(backend, raw)
+
+ v2_source = _read(V2_LIB)
+ v2_block = _assigned_object(v2_source, "export const DM_DEFAULTS")
+ v2_defaults = {}
+ for key, raw in re.findall(r"^\s*([a-z0-9_]+):\s*(.+?),\s*$", v2_block, re.MULTILINE):
+ raw = raw.strip()
+ if raw in ("true", "false"):
+ v2_defaults[key] = raw == "true"
+ elif re.fullmatch(r"-?\d+", raw):
+ v2_defaults[key] = int(raw)
+ elif raw.startswith("'"):
+ v2_defaults[key] = raw.strip("'")
+ else:
+ constant = re.search(rf"export const {raw} = '([^']+)';", v2_source)
+ assert constant, f"Unresolved V2 default constant {raw}"
+ v2_defaults[key] = constant.group(1)
+
+ mismatches = [
+ f"{key}: V2 {value!r} != server {backend_defaults.get(key)!r}"
+ for key, value in v2_defaults.items()
+ if backend_defaults.get(key) != value
+ ]
+ assert not mismatches, "DM_DEFAULTS drifted from the server:\n " + "\n ".join(mismatches)
+
+ bounds_block = _assigned_object(v2_source, "export const DM_NUMBER_BOUNDS")
+ v2_max = {
+ key: int(value)
+ for key, value in re.findall(r"([a-z0-9_]+): \{ min: \d+, max: (\d+)", bounds_block)
+ }
+ wrong = [
+ f"{key}: V2 max {v2_max.get(key)} != {constant} {_backend_constant(backend, constant)}"
+ for key, constant in UPPER_BOUNDS.items()
+ if v2_max.get(key) != _backend_constant(backend, constant)
+ ]
+ assert not wrong, "Form bounds drifted from the server clamps:\n " + "\n ".join(wrong)
+
+ print(f" {len(v2_defaults)} default(s) and {len(UPPER_BOUNDS)} bound(s) match the server.")
+ return True
+
+
+if __name__ == "__main__":
+ tests = [
+ test_backup_recovery_navigation_is_fully_described,
+ test_v2_saves_every_setting_the_classic_page_saves,
+ test_v2_calls_every_endpoint_the_classic_page_calls,
+ test_typed_confirmations_match_the_server,
+ test_defaults_and_bounds_match_the_server,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ results.append(bool(test()))
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ import traceback
+
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_v2_admin_capability_placement.py b/functional_tests/test_v2_admin_capability_placement.py
index 7680e9972..60486605f 100644
--- a/functional_tests/test_v2_admin_capability_placement.py
+++ b/functional_tests/test_v2_admin_capability_placement.py
@@ -2,12 +2,13 @@
#!/usr/bin/env python3
"""
Functional test pinning where the V2 admin surface files each capability toggle.
-Version: 0.261.274
+Version: 0.261.275
Implemented in: 0.261.047
Operations described in full: 0.261.269
Data Lifecycle added to the fully described groups in: 0.261.272
Governance added to the fully described groups in: 0.261.273
Scale described in full: 0.261.274
+Backup & Recovery added to the fully described groups in: 0.261.275
Settings that ``admin_settings_fields.py`` does not describe are still shown in the
V2 admin UI, by scanning the settings document for ``enable_*`` booleans and
@@ -32,9 +33,9 @@
invariants so the misfiling cannot come back:
1. The Appearance, Chat, Security, Governance, Agents & Actions, Workspaces, Data
- Lifecycle and Operations groups are fully described by the schema, so they must
- receive *no* guessed rows at all. A new undeclared key that lands in any of them
- fails here, and the fix is to declare it in its real section.
+ Lifecycle, Backup & Recovery, Scale and Operations groups are fully described by the
+ schema, so they must receive *no* guessed rows at all. A new undeclared key that
+ lands in any of them fails here, and the fix is to declare it in its real section.
2. The keys that were moved stay declared where they were moved to.
3. Keys that are not editable settings at all stay suppressed rather than
declared. ``enable_tabular_processing_plugin`` is the clearest case: it is
@@ -107,6 +108,8 @@
# Groups whose sections are described by the schema in full. A guessed row landing
# in one of these is a key that was filed by word stems into a group that has a
# real home for everything it owns, which means it is in the wrong place.
+# Backup & Recovery is described by one component per section (0.261.275); its
+# settings live in a separate document, so no settings-document key belongs there.
FULLY_DESCRIBED_GROUP_IDS = (
APPEARANCE_GROUP_ID,
"chat",
@@ -115,6 +118,7 @@
"agents-actions",
"workspaces",
"data-lifecycle",
+ "backup-recovery",
"operations",
"scale",
)
diff --git a/functional_tests/test_v2_admin_field_renderer_coverage.py b/functional_tests/test_v2_admin_field_renderer_coverage.py
index 14919b5e5..233ca5eb3 100644
--- a/functional_tests/test_v2_admin_field_renderer_coverage.py
+++ b/functional_tests/test_v2_admin_field_renderer_coverage.py
@@ -2,8 +2,9 @@
# test_v2_admin_field_renderer_coverage.py
"""
Functional test that the V2 admin UI renders every field the schema can declare.
-Version: 0.261.039
+Version: 0.261.275
Implemented in: 0.261.039
+Remote-asset scan extended to component subfolders in: 0.261.275
The V2 admin surface is driven by ``admin_settings_fields.py``. That indirection has one
silent failure mode: the schema can declare a field type, or name a bespoke component,
@@ -130,18 +131,20 @@ def test_admin_components_use_no_remote_assets():
)
offenders = []
- for path in sorted(ADMIN_COMPONENTS_DIR.glob("*.tsx")):
+ # Subfolders too: the Backup & Recovery cards live in components/admin/dataManagement.
+ component_files = sorted(ADMIN_COMPONENTS_DIR.rglob("*.tsx"))
+ for path in component_files:
content = path.read_text(encoding="utf-8")
for line_number, line in enumerate(content.splitlines(), start=1):
if forbidden.search(line):
- offenders.append(f"{path.name}:{line_number}: {line.strip()[:100]}")
+ offenders.append(f"{path.relative_to(ADMIN_COMPONENTS_DIR)}:{line_number}: {line.strip()[:100]}")
assert not offenders, (
"These admin components reference a remote asset. Browser assets must be "
"vendored locally under static/:\n " + "\n ".join(offenders)
)
- checked = len(list(ADMIN_COMPONENTS_DIR.glob("*.tsx")))
+ checked = len(component_files)
print(f" {checked} admin component file(s) reference only local assets.")
return True
diff --git a/scripts/check_xss_sinks.py b/scripts/check_xss_sinks.py
index b929d15ad..35b20d97a 100644
--- a/scripts/check_xss_sinks.py
+++ b/scripts/check_xss_sinks.py
@@ -601,6 +601,9 @@ def collect_marked_parse_issues(
'generatedArtifactDownloadUrl',
'groupWorkspaceDocumentPath',
'groupWorkspacePath',
+ # apiUrl() over the literal '/api/admin/data-management/jobs/' prefix, an
+ # encodeURIComponent job id and a literal, encoded status query.
+ 'migrationManifestUrl',
'publicWorkspaceDocumentPath',
'publicWorkspacePath',
'sameSiteAddress',
diff --git a/ui_tests/fixtures/v2_admin_data_management.py b/ui_tests/fixtures/v2_admin_data_management.py
new file mode 100644
index 000000000..f0d7ed5be
--- /dev/null
+++ b/ui_tests/fixtures/v2_admin_data_management.py
@@ -0,0 +1,599 @@
+# v2_admin_data_management.py
+"""
+In-memory Backup & Recovery API for the V2 Admin Settings browser tests.
+Version: 0.261.275
+Implemented in: 0.261.275
+
+Serves the real built SPA through ``AdminSettingsFixture`` and answers every
+``/api/admin/data-management/*`` call from memory, so the Backup & Recovery cards can be
+exercised end to end with no application server, no Azure services and no live writes.
+Every request is recorded in order, which is what lets a test prove that settings were
+saved *before* a job was queued. History endpoints implement the same continuation-token, page-size, count-query and
+date-filter contracts as the real API, and the review knobs accept server-shaped
+check payloads so tests can cover blocking restore and migration evidence. Anything the
+stub does not recognise fails the test through the base fixture's unexpected-request list.
+"""
+
+import copy
+import re
+import sys
+from pathlib import Path
+from urllib.parse import parse_qs, unquote, urlsplit
+
+from playwright.sync_api import Page, Route
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from v2_admin_settings import AdminSettingsFixture # noqa: E402
+
+
+REDACTED = "***REDACTED***"
+API = "/api/admin/data-management"
+
+# Every Backup & Recovery section, in navigation order.
+BACKUP_RECOVERY_SECTIONS = {
+ "backup-recovery": (
+ "data-management-readiness-section",
+ "data-management-backup-section",
+ "data-management-schedule-section",
+ "data-management-storage-section",
+ "data-management-encryption-section",
+ "data-management-migration-section",
+ "data-management-backup-inventory-section",
+ "data-management-cosmos-editor-section",
+ "data-management-jobs-section",
+ ),
+}
+
+# The keys the classic page saves, and therefore the only keys a save may send.
+EDITABLE_KEYS = (
+ "enabled", "full_backup_frequency", "scheduled_time_utc", "retention_value",
+ "retention_unit", "retention_days", "partial_backups_enabled", "low_impact_mode",
+ "include_cosmos", "include_ai_search", "include_source_blobs",
+ "backup_storage_authentication_type", "backup_storage_blob_endpoint",
+ "backup_storage_container_name", "backup_storage_connection_string",
+ "backup_storage_path_prefix", "encryption_enabled", "backup_max_parallel_operations",
+ "backup_retry_count", "backup_blob_max_parallel_operations", "backup_blob_chunk_size_mib",
+ "backup_blob_retry_count", "backup_capacity_failure_policy",
+ "backup_temporary_source_ru_enabled", "backup_temporary_source_ru",
+ "target_cosmos_authentication_type", "target_cosmos_endpoint", "target_cosmos_database_name",
+ "target_cosmos_key", "target_cosmos_subscription_id", "target_cosmos_resource_group",
+ "target_ai_search_authentication_type", "target_ai_search_endpoint", "target_ai_search_key",
+ "target_enhanced_citations_storage_authentication_type",
+ "target_enhanced_citations_storage_blob_endpoint",
+ "target_enhanced_citations_storage_connection_string", "migration_max_parallel_operations",
+ "migration_retry_count", "migration_skip_recent_within_hours",
+ "migration_temporary_destination_ru_enabled", "migration_temporary_destination_ru",
+)
+
+SECRET_KEYS = (
+ "backup_storage_connection_string",
+ "target_cosmos_key",
+ "target_ai_search_key",
+ "target_enhanced_citations_storage_connection_string",
+)
+
+# The public metadata the server returns with every Cosmos editor container, query and document.
+COSMOS_CONTAINER = {
+ "id": "user_settings", "name": "user_settings", "display_name": "User settings", "category": "users",
+ "partition_key_path": "/user_id", "partition_key_field": "user_id", "max_page_size": 100,
+ "empty_query_limit": 100, "editable": True,
+}
+
+
+def default_settings():
+ """A configured deployment: storage, a Key Vault key and a destination are set."""
+ return {
+ "enabled": False,
+ "full_backup_frequency": "weekly",
+ "scheduled_time_utc": "03:00",
+ "retention_value": 30,
+ "retention_unit": "days",
+ "retention_days": 30,
+ "partial_backups_enabled": True,
+ "low_impact_mode": True,
+ "include_cosmos": True,
+ "include_ai_search": True,
+ "include_source_blobs": True,
+ "backup_storage_authentication_type": "managed_identity",
+ "backup_storage_blob_endpoint": "https://backups.blob.core.windows.net",
+ "backup_storage_container_name": "simplechat-backups",
+ "backup_storage_connection_string": "",
+ "backup_storage_path_prefix": "simplechat-backups",
+ "encryption_enabled": True,
+ "encryption_key_reference": REDACTED,
+ "encryption_key_storage": "key_vault",
+ "backup_max_parallel_operations": 4,
+ "backup_retry_count": 5,
+ "backup_blob_max_parallel_operations": 4,
+ "backup_blob_chunk_size_mib": 8,
+ "backup_blob_retry_count": 5,
+ "backup_capacity_failure_policy": "continue_without_boost",
+ "backup_temporary_source_ru_enabled": False,
+ "backup_temporary_source_ru": 10000,
+ "target_cosmos_authentication_type": "key",
+ "target_cosmos_endpoint": "https://destination.documents.azure.com:443/",
+ "target_cosmos_database_name": "SimpleChat",
+ "target_cosmos_key": REDACTED,
+ "target_cosmos_subscription_id": "",
+ "target_cosmos_resource_group": "",
+ "target_ai_search_authentication_type": "managed_identity",
+ "target_ai_search_endpoint": "https://destination.search.windows.net",
+ "target_ai_search_key": "",
+ "target_enhanced_citations_storage_authentication_type": "managed_identity",
+ "target_enhanced_citations_storage_blob_endpoint": "https://destination-citations.blob.core.windows.net",
+ "target_enhanced_citations_storage_connection_string": "",
+ "migration_max_parallel_operations": 8,
+ "migration_retry_count": 5,
+ "migration_skip_recent_within_hours": 0,
+ "migration_temporary_destination_ru_enabled": False,
+ "migration_temporary_destination_ru": 10000,
+ "next_full_backup_run_at": None,
+ "next_partial_backup_run_at": None,
+ "enhanced_citations_enabled": True,
+ "include_source_blobs_manageable": True,
+ "key_vault_secret_storage_enabled": True,
+ "key_vault_name_configured": True,
+ "operational_business_hours_warning": (
+ "We suggest not running backups, restores, or migrations during your operational "
+ "business hours."
+ ),
+ }
+
+
+def backup_row(backup_id, backup_type="full", status="completed", **extra):
+ row = {
+ "id": backup_id,
+ "backup_type": backup_type,
+ "status": status,
+ "created_at": "2026-09-01T03:00:00+00:00",
+ "completed_at": "2026-09-01T03:20:00+00:00",
+ "scheduled": True,
+ "manifest_path": f"simplechat-backups/{backup_id}/manifest.json",
+ "base_prefix": f"simplechat-backups/{backup_id}",
+ "artifact_count": 12,
+ "bytes": 5_242_880,
+ "record_count": 1200,
+ "blob_count": 40,
+ "warning_count": 0,
+ "encrypted": True,
+ "last_message": "Backup completed.",
+ "can_delete": True,
+ }
+ row.update(extra)
+ return row
+
+
+class DataManagementFixture(AdminSettingsFixture):
+ """The V2 admin page with a closed, in-memory Backup & Recovery API."""
+
+ def __init__(self, page: Page, *, sections=None, **kwargs):
+ # The base fixture installs its route handler, which records into this list.
+ self.writes = []
+ super().__init__(page, sections=sections or BACKUP_RECOVERY_SECTIONS, **kwargs)
+ self.dm_settings = default_settings()
+ self.dm_requests = []
+ self.reject_next_dm_save = None
+ self.backups = [
+ backup_row("11111111-1111-4111-8111-111111111111", "full"),
+ backup_row("22222222-2222-4222-8222-222222222222", "partial", warning_count=1),
+ ]
+ self.jobs = {}
+ self.job_items = {}
+ self.progress_polls = {}
+ self.restore_review = {"ready": True, "blocker_count": 0, "warning_count": 0}
+ self.migration_review = {"ready": True, "blocker_count": 0, "warning_count": 0}
+ self.catalog_count_failures = 0
+ self.queue_status = 202
+ self.queue_error = None
+ self.catalog = {
+ "users": [
+ {"id": f"user-{index}", "label": f"User {index}", "description": f"user{index}@contoso.com", "document_count": index}
+ for index in range(1, 31)
+ ],
+ "groups": [{"id": "group-1", "label": "Finance", "description": "Finance group", "document_count": 4}],
+ "public_workspaces": [],
+ }
+ self.cosmos_documents = {
+ "doc-1": {"id": "doc-1", "user_id": "user-1", "name": "Settings", "_etag": "etag-1", "_ts": 1},
+ }
+ self.cosmos_container_failures = 0
+
+ # -- routing ---------------------------------------------------------------------
+
+ def _route(self, route: Route):
+ request = route.request
+ path = urlsplit(request.url).path
+ if path.startswith("/api/") and request.method in ("POST", "PUT", "PATCH", "DELETE"):
+ # One ordered record of writes across the main and backup APIs, so a test can
+ # prove which save happened first.
+ self.writes.append((request.method, path))
+ if path.startswith(API):
+ self._data_management(route)
+ else:
+ super()._route(route)
+
+ def _record(self, route):
+ request = route.request
+ body = None
+ if request.post_data:
+ try:
+ body = request.post_data_json
+ except Exception:
+ body = request.post_data
+ parsed = urlsplit(request.url)
+ entry = {
+ "method": request.method,
+ "path": parsed.path[len(API):],
+ "query": parse_qs(parsed.query, keep_blank_values=True),
+ "body": body,
+ }
+ self.dm_requests.append(entry)
+ return entry
+
+ def requests_to(self, method, path_pattern):
+ return [
+ entry for entry in self.dm_requests
+ if entry["method"] == method and re.fullmatch(path_pattern, entry["path"])
+ ]
+
+ def _data_management(self, route: Route):
+ entry = self._record(route)
+ method, path = entry["method"], entry["path"]
+
+ if path == "/settings" and method == "GET":
+ return route.fulfill(json={"success": True, "settings": self.dm_settings})
+ if path == "/settings" and method == "PUT":
+ return self._save_settings(route, entry["body"])
+ if path == "/encryption-key" and method == "POST":
+ self.dm_settings.update({"encryption_enabled": True, "encryption_key_storage": "key_vault", "encryption_key_reference": REDACTED})
+ return route.fulfill(json={"success": True, "settings": self.dm_settings})
+ if path == "/storage/test" and method == "POST":
+ return route.fulfill(json={
+ "success": True,
+ "container_name": entry["body"]["settings"]["backup_storage_container_name"],
+ "container_exists": True,
+ "container_created": False,
+ })
+ if path == "/target/cosmos/test" and method == "POST":
+ return route.fulfill(json={"success": True, "database_name": "SimpleChat", "migration_access": {"container_count": 7}})
+ if path == "/target/cosmos/ru-boost/test" and method == "POST":
+ return route.fulfill(json={"success": True, "target_ru": 10000, "targets": [{"scope": "database"}]})
+ if path == "/target/search/test" and method == "POST":
+ return route.fulfill(json={"success": True, "existing_indexes": ["simplechat-user-index"], "missing_indexes": []})
+ if path == "/target/enhanced-citation-storage/test" and method == "POST":
+ return route.fulfill(json={"success": True, "containers": [{"container_name": "user-documents", "container_exists": True}]})
+ if path == "/backups" and method == "GET":
+ return route.fulfill(json=self._backup_page(entry["query"]))
+ if path == "/backups/retention/cleanup" and method == "POST":
+ return route.fulfill(json={"success": True, "cleanup": {
+ "success": True, "candidate_count": 0, "deleted_count": 0, "errors": [],
+ "cutoff_at": "2026-08-07T00:00:00+00:00", "retention_days": self.dm_settings["retention_days"],
+ }})
+ match = re.fullmatch(r"/backups/([^/]+)", path)
+ if match and method == "DELETE":
+ backup_id = unquote(match.group(1))
+ self.backups = [backup for backup in self.backups if backup["id"] != backup_id]
+ return route.fulfill(json={"success": True, "cleanup": {"job_id": backup_id, "deleted_blob_count": 12}})
+ if path == "/restore/review" and method == "POST":
+ review = copy.deepcopy(self.restore_review)
+ review.update({
+ "checks": review.get("checks") or [{
+ "id": "manifest_integrity",
+ "label": "Backup manifest integrity",
+ "status": "pass",
+ "message": "Restore-safe.",
+ }],
+ "review_fingerprint": "restore-fingerprint",
+ "summary": {"artifact_count": 12, "service_counts": {"cosmos": 10, "ai_search": 2, "source_blobs": 0}, "warnings": 0, "failed_resource_names": []},
+ })
+ if review.get("ready"):
+ review.update({"authorization_token": "restore-token", "authorization_expires_at": "2099-01-01T00:00:00+00:00"})
+ return route.fulfill(json={"success": True, "review": review})
+ if path == "/migration/review" and method == "POST":
+ review = copy.deepcopy(self.migration_review)
+ summary = self._migration_summary(entry["body"]["migration_plan"])
+ review.update({
+ "review_fingerprint": "migration-fingerprint",
+ "summary": summary,
+ "preview": {"estimated_outcomes": {
+ "create_count": 3,
+ "update_count": 2,
+ "delete_count": 1,
+ "conflict_count": 4,
+ }},
+ "checks": review.get("checks") or [{
+ "id": "scope",
+ "label": "Migration scope",
+ "workflow_step": "scope",
+ "status": "pass",
+ "summary": f"{sum(summary[target_type]['count'] for target_type in self.catalog)} principal scopes are included.",
+ "details": {
+ target_type: {
+ "mode": summary[target_type]["mode"],
+ "count": summary[target_type]["count"],
+ "document_count": summary[target_type]["document_count"],
+ "include_documents": summary[target_type]["include_documents"],
+ }
+ for target_type in self.catalog
+ },
+ }],
+ })
+ if review.get("ready"):
+ review.update({"authorization_token": "migration-token", "authorization_expires_at": "2099-01-01T00:00:00+00:00"})
+ return route.fulfill(json={"success": True, "review": review})
+ match = re.fullmatch(r"/migration/catalog/([a-z_]+)", path)
+ if match and method == "GET":
+ if (entry["query"].get("page_size") or [""])[0] == "1" and self.catalog_count_failures:
+ self.catalog_count_failures -= 1
+ return route.fulfill(status=503, json={"success": False, "error": "Catalog count unavailable."})
+ return route.fulfill(json=self._catalog_page(match.group(1), entry["query"]))
+ if path == "/jobs" and method == "POST":
+ return self._queue_job(route, entry["body"])
+ if path == "/jobs" and method == "GET":
+ return route.fulfill(json=self._job_page(entry["query"]))
+ match = re.fullmatch(r"/jobs/([^/]+)(/progress|/retry|/cancel)?", path)
+ if match:
+ return self._job_action(route, unquote(match.group(1)), match.group(2) or "", method)
+ if path == "/cosmos-editor/danger-acknowledgement" and method == "POST":
+ return route.fulfill(json={"success": True})
+ if path == "/cosmos-editor/containers" and method == "GET":
+ if self.cosmos_container_failures:
+ self.cosmos_container_failures -= 1
+ return route.fulfill(status=503, json={"success": False, "error": "Cosmos DB containers could not be loaded."})
+ return route.fulfill(json={"success": True, "containers": [COSMOS_CONTAINER]})
+ if path == "/cosmos-editor/query" and method == "POST":
+ custom = bool((entry["body"].get("query") or "").strip())
+ return route.fulfill(json={"success": True, "container": COSMOS_CONTAINER, "items": [
+ {"id": document["id"], "partition_key": document["user_id"], "etag": document["_etag"], "selectable": True, "preview": f"name: {document['name']}"}
+ for document in self.cosmos_documents.values()
+ ], "count": len(self.cosmos_documents),
+ # Only a custom query pages on; a blank browse never returns a token.
+ "continuation_token": "next-page" if custom else None, "has_more": custom,
+ "query": {"mode": "custom" if custom else "empty"}})
+ if path == "/cosmos-editor/document" and method == "POST":
+ document = self.cosmos_documents[entry["body"]["id"]]
+ return route.fulfill(json={"success": True, "container": COSMOS_CONTAINER, "document": document, "id": document["id"], "partition_key": document["user_id"], "etag": document["_etag"]})
+ if path == "/cosmos-editor/document" and method == "PUT":
+ return self._save_cosmos_document(route, entry["body"])
+
+ self.unexpected_requests.append(f"{method} {API}{path}")
+ return route.fulfill(status=404, json={"success": False, "error": "Unexpected data-management request."})
+
+ # -- handlers --------------------------------------------------------------------
+
+ def _save_settings(self, route, body):
+ if self.reject_next_dm_save:
+ message, self.reject_next_dm_save = self.reject_next_dm_save, None
+ return route.fulfill(status=400, json={"success": False, "error": message})
+ unexpected = sorted(set(body) - set(EDITABLE_KEYS))
+ assert not unexpected, f"A save sent keys the classic page never sends: {unexpected}"
+ for key, value in body.items():
+ if key in SECRET_KEYS:
+ if value == REDACTED:
+ continue
+ self.dm_settings[key] = REDACTED if value else ""
+ else:
+ self.dm_settings[key] = value
+ return route.fulfill(json={"success": True, "settings": self.dm_settings})
+
+ def _backup_page(self, query):
+ status = (query.get("status") or [""])[0]
+ backup_type = (query.get("backup_type") or [""])[0]
+ scheduled = (query.get("scheduled") or ["all"])[0]
+ created_from = (query.get("created_from") or [""])[0]
+ created_to = (query.get("created_to") or [""])[0]
+ rows = [
+ backup for backup in self.backups
+ if (not backup_type or backup["backup_type"] == backup_type)
+ and (not status or (status == "available" and backup["status"] in ("completed", "completed_with_warnings")) or backup["status"] == status)
+ and (scheduled == "all" or (scheduled == "scheduled") == bool(backup.get("scheduled")))
+ and (not created_from or (backup.get("created_at") or "")[:10] >= created_from)
+ and (not created_to or (backup.get("created_at") or "")[:10] <= created_to)
+ ]
+ available = [backup for backup in self.backups if backup["status"] in ("completed", "completed_with_warnings")]
+ latest_full = next((backup for backup in available if backup["backup_type"] == "full"), None)
+ latest_partial = next((backup for backup in available if backup["backup_type"] == "partial"), None)
+ page, pagination = self._paginate(rows, query)
+ return {
+ "success": True,
+ "summary": {
+ "available": len(available),
+ "full": len([backup for backup in available if backup["backup_type"] == "full"]),
+ "partial": len([backup for backup in available if backup["backup_type"] == "partial"]),
+ "total": len(self.backups),
+ "latest_full": latest_full,
+ "latest_partial": latest_partial,
+ },
+ "backups": page,
+ "pagination": pagination,
+ "filters": {
+ "status": status,
+ "backup_type": backup_type,
+ "scheduled": scheduled,
+ "created_from": created_from,
+ "created_to": created_to,
+ },
+ }
+
+ def _catalog_page(self, target_type, query):
+ search = (query.get("search") or [""])[0].lower()
+ token = (query.get("continuation_token") or [""])[0]
+ page_size = int((query.get("page_size") or ["25"])[0])
+ items = [item for item in self.catalog.get(target_type, []) if search in f"{item['label']} {item['description']}".lower()]
+ start = int(token or 0)
+ page = items[start : start + page_size]
+ has_more = start + page_size < len(items)
+ return {
+ "type": target_type, "items": page, "total_count": len(items), "page_size": page_size,
+ "has_more": has_more, "continuation_token": str(start + page_size) if has_more else "",
+ }
+
+ def _migration_summary(self, migration_plan):
+ summary = {}
+ for target_type, items in self.catalog.items():
+ entry = migration_plan.get(target_type) or {}
+ mode = entry.get("mode") or "none"
+ ids = entry.get("ids") if isinstance(entry.get("ids"), list) else []
+ if mode == "all":
+ count = len(items)
+ scoped_items = items
+ elif mode == "selected":
+ id_set = set(ids)
+ scoped_items = [item for item in items if item["id"] in id_set]
+ count = len(ids)
+ else:
+ scoped_items = []
+ count = 0
+ include_documents = bool(entry.get("include_documents"))
+ summary[target_type] = {
+ "mode": mode,
+ "count": count,
+ "document_count": sum(item.get("document_count") or 0 for item in scoped_items) if include_documents else 0,
+ "include_documents": include_documents,
+ "ids": ids[:50],
+ "ids_truncated": len(ids) > 50,
+ }
+ summary["include_ai_search"] = bool(migration_plan.get("include_ai_search"))
+ summary["include_source_blobs"] = bool(migration_plan.get("include_source_blobs"))
+ summary["target_ai_search_writes_frozen"] = bool(migration_plan.get("target_ai_search_writes_frozen"))
+ summary["migration_mode"] = migration_plan.get("migration_mode")
+ summary["baseline_job_id"] = migration_plan.get("baseline_job_id")
+ summary["mirror_deletions_confirmed"] = bool(migration_plan.get("mirror_deletions_confirmed"))
+ return summary
+
+ def _queue_job(self, route, body):
+ if self.queue_error:
+ status, payload = self.queue_error
+ self.queue_error = None
+ return route.fulfill(status=status, json=payload)
+ job_id = f"{body['operation']}-{len(self.jobs) + 1}"
+ job = {
+ "id": job_id,
+ "operation": body["operation"],
+ "backup_type": body.get("backup_type"),
+ "status": "queued",
+ "created_at": "2026-09-02T10:00:00+00:00",
+ "requested_by_email": "admin@contoso.com",
+ "scheduled": False,
+ "progress": {"current_step": "queued", "percent_complete": 0, "completed_steps": 0, "total_steps": 4},
+ "warnings": [],
+ "result": {},
+ "options": body.get("options") or {},
+ "can_retry": False,
+ "can_cancel": True,
+ }
+ self.jobs[job_id] = job
+ self.job_items[job_id] = [{"id": f"{job_id}-1", "job_id": job_id, "step_name": "queued", "status": "queued", "message": "Job queued.", "created_at": job["created_at"]}]
+ return route.fulfill(status=self.queue_status, json={"success": True, "job": {key: value for key, value in job.items() if key != "options"}})
+
+ def _public_job(self, job):
+ return {key: value for key, value in job.items() if key != "options"}
+
+ def _job_page(self, query):
+ operation = (query.get("operation") or [""])[0]
+ status = (query.get("status") or [""])[0]
+ scheduled = (query.get("scheduled") or ["all"])[0]
+ created_from = (query.get("created_from") or [""])[0]
+ created_to = (query.get("created_to") or [""])[0]
+ jobs = [
+ self._public_job(job) for job in reversed(list(self.jobs.values()))
+ if (not operation or job["operation"] == operation)
+ and (not status or job["status"] == status)
+ and (scheduled == "all" or (scheduled == "scheduled") == bool(job.get("scheduled")))
+ and (not created_from or (job.get("created_at") or "")[:10] >= created_from)
+ and (not created_to or (job.get("created_at") or "")[:10] <= created_to)
+ ]
+ page, pagination = self._paginate(jobs, query)
+ return {
+ "success": True,
+ "jobs": page,
+ "pagination": pagination,
+ "filters": {
+ "operation": operation,
+ "status": status,
+ "scheduled": scheduled,
+ "created_from": created_from,
+ "created_to": created_to,
+ },
+ }
+
+ def _job_action(self, route, job_id, action, method):
+ job = self.jobs.get(job_id)
+ if not job:
+ return route.fulfill(status=404, json={"success": False, "error": "Data Management job was not found."})
+ if action == "" and method == "GET":
+ return route.fulfill(json={"success": True, "job": self._public_job(job), "items": self.job_items.get(job_id, [])})
+ if action == "/progress" and method == "GET":
+ polls = self.progress_polls.get(job_id, 0) + 1
+ self.progress_polls[job_id] = polls
+ if polls >= 2 and job["status"] in ("queued", "running"):
+ job.update({"status": "completed", "can_cancel": False, "completed_at": "2026-09-02T10:05:00+00:00", "progress": {"current_step": "completed", "percent_complete": 100, "completed_steps": 4, "total_steps": 4}, "last_message": "Job completed."})
+ elif job["status"] == "queued":
+ job.update({"status": "running", "progress": {"current_step": "export", "percent_complete": 40, "completed_steps": 1, "total_steps": 4}})
+ return route.fulfill(json={"success": True, "job": self._public_job(job)})
+ if action == "/cancel" and method == "POST":
+ job.update({"cancel_requested_at": "2026-09-02T10:01:00+00:00", "can_cancel": False})
+ return route.fulfill(status=202, json={"success": True, "job": self._public_job(job)})
+ if action == "/retry" and method == "POST":
+ job.update({"status": "queued", "can_retry": False, "can_cancel": True})
+ return route.fulfill(status=202, json={"success": True, "job": self._public_job(job)})
+ self.unexpected_requests.append(f"{method} {API}/jobs/{job_id}{action}")
+ return route.fulfill(status=404, json={"success": False, "error": "Unexpected job request."})
+
+ def _save_cosmos_document(self, route, body):
+ document = self.cosmos_documents[body["id"]]
+ assert body["confirmation_accepted"] is True
+ assert body["confirmation_phrase"] == "I understand this can damage system data"
+ if body["etag"] != document["_etag"]:
+ return route.fulfill(status=409, json={"success": False, "error": "Cosmos DB document changed after it was opened. Refresh before saving again."})
+ updated = dict(body["document"])
+ updated["_etag"] = f"{document['_etag']}-next"
+ self.cosmos_documents[body["id"]] = updated
+ return route.fulfill(json={
+ "success": True, "container": COSMOS_CONTAINER, "document": updated, "id": updated["id"], "partition_key": updated["user_id"], "etag": updated["_etag"],
+ "change_summary": {"changed_paths": ["name"], "changed_count": 1, "added_count": 0, "removed_count": 0, "updated_count": 1},
+ })
+
+ # -- helpers ---------------------------------------------------------------------
+
+ def _paginate(self, rows, query):
+ page_size = int((query.get("page_size") or ["25"])[0])
+ start = int((query.get("continuation_token") or ["0"])[0] or 0)
+ page = rows[start:start + page_size]
+ next_start = start + page_size
+ has_more = next_start < len(rows)
+ return page, {
+ "page_size": page_size,
+ "returned_count": len(page),
+ "has_more": has_more,
+ "next_token": str(next_start) if has_more else None,
+ }
+
+ def add_job(self, job_id, **extra):
+ """Seed one job into history, as a finished or failed run would leave it."""
+ job = {
+ "id": job_id,
+ "operation": "backup",
+ "backup_type": "full",
+ "status": "failed",
+ "created_at": "2026-09-01T09:00:00+00:00",
+ "completed_at": "2026-09-01T09:10:00+00:00",
+ "requested_by_email": "admin@contoso.com",
+ "scheduled": False,
+ "progress": {"current_step": "export", "percent_complete": 40, "completed_steps": 1, "total_steps": 4},
+ "warnings": [],
+ "result": {},
+ "last_message": "Backup failed.",
+ "can_retry": True,
+ "can_cancel": False,
+ }
+ job.update(extra)
+ self.jobs[job_id] = job
+ self.job_items[job_id] = []
+ return job
+
+ def open_backup_recovery(self, **kwargs):
+ kwargs.setdefault("ready_region", "Start Here")
+ kwargs.setdefault("width", 1600)
+ self.open(**kwargs)
diff --git a/ui_tests/test_v2_admin_backup_recovery.py b/ui_tests/test_v2_admin_backup_recovery.py
new file mode 100644
index 000000000..5ec0203c3
--- /dev/null
+++ b/ui_tests/test_v2_admin_backup_recovery.py
@@ -0,0 +1,1270 @@
+# test_v2_admin_backup_recovery.py
+"""
+Browser coverage for V2 Admin Settings > Backup & Recovery.
+Version: 0.261.275
+Implemented in: 0.261.275
+
+The category used to render "No settings match". It now draws one card per navigation
+section over the classic data-management API. These tests run the built SPA against an
+in-memory copy of that API (``fixtures/v2_admin_data_management.py``) and check the
+contracts that matter to an administrator: every card renders, backup settings join the
+page's Save bar and save as the whole document the classic page sends, actions that run
+against saved settings save first, destructive actions demand their typed confirmations,
+history filters and continuation tokens stay server-backed, responsive workbenches do not
+overflow, keyboard list navigation works, and leaving the page with unsaved work asks first.
+No live settings are written.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures"))
+
+from v2_admin_data_management import ( # noqa: E402
+ BACKUP_RECOVERY_SECTIONS,
+ EDITABLE_KEYS,
+ REDACTED,
+ DataManagementFixture,
+ backup_row,
+)
+from v2_admin_settings import connect_options # noqa: E402,F401
+
+
+pytestmark = pytest.mark.ui
+
+ARTIFACTS = "v2_admin_backup_recovery"
+CARD_TITLES = ("Start Here", "Backup", "Schedule", "Storage", "Encryption", "Migration",
+ "Backup Inventory & Restore", "Cosmos Editor", "Jobs")
+
+
+@pytest.fixture
+def dm(page):
+ fixture = DataManagementFixture(page)
+ page.emulate_media(reduced_motion="reduce")
+ yield fixture
+ fixture.assert_clean()
+
+
+def region(page, name):
+ return page.get_by_role("region", name=name, exact=True)
+
+
+def save_bar(page):
+ return page.get_by_role("status").filter(has_text=re.compile(r"unsaved change"))
+
+
+@pytest.mark.parametrize("theme", ["light", "dark"])
+def test_every_backup_recovery_card_renders(dm, theme):
+ dm.open_backup_recovery(theme=theme)
+ page = dm.page
+ for title in CARD_TITLES:
+ expect(region(page, title)).to_be_visible()
+ expect(page.get_by_text(re.compile("No settings match"))).to_have_count(0)
+ expect(page.get_by_test_id("dm-readiness")).to_contain_text("Backup storage")
+ expect(save_bar(page)).to_have_count(0)
+ dm.capture(f"backup-recovery-{theme}", ARTIFACTS)
+
+
+def test_backup_settings_join_the_save_bar(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ schedule = region(page, "Schedule")
+ schedule.get_by_text("Scheduled backups", exact=True).click()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+
+ retention = schedule.get_by_role("spinbutton").first
+ retention.fill("2")
+ schedule.get_by_role("combobox", name=re.compile("unit", re.I)).select_option("weeks")
+ expect(save_bar(page)).to_contain_text("3 unsaved changes")
+
+ page.get_by_role("button", name="Save changes").click()
+ expect(save_bar(page)).to_have_count(0)
+
+ saves = dm.requests_to("PUT", r"/settings")
+ assert len(saves) == 1, saves
+ body = saves[0]["body"]
+ assert set(body) == set(EDITABLE_KEYS), sorted(set(EDITABLE_KEYS) ^ set(body))
+ assert body["enabled"] is True
+ assert body["retention_value"] == 2 and body["retention_unit"] == "weeks" and body["retention_days"] == 14
+ assert body["target_cosmos_key"] == REDACTED, "an untouched secret must round-trip as the placeholder"
+ assert not dm.patches, "backup settings are not main settings"
+
+
+def test_discard_drops_backup_edits(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ region(page, "Schedule").get_by_text("Scheduled backups", exact=True).click()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+ page.get_by_role("button", name="Discard").click()
+ expect(save_bar(page)).to_have_count(0)
+ assert not dm.requests_to("PUT", r"/settings")
+
+
+def test_a_rejected_backup_save_keeps_the_edit(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ dm.reject_next_dm_save = "Backup storage must use a dedicated Azure Storage account."
+ region(page, "Schedule").get_by_text("Scheduled backups", exact=True).click()
+ page.get_by_role("button", name="Save changes").click()
+ expect(page.get_by_text("Backup storage must use a dedicated Azure Storage account.").first).to_be_visible()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+ # The browser logs the deliberate 400; nothing else may have failed.
+ dm.errors[:] = [error for error in dm.errors if "status of 400" not in error]
+
+
+def test_queueing_a_backup_saves_pending_settings_first(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ region(page, "Schedule").get_by_text("Scheduled backups", exact=True).click()
+ backup = region(page, "Backup")
+ backup.get_by_role("button", name=re.compile("Save and queue full backup")).click()
+ expect(backup.get_by_text(re.compile("queued", re.I)).first).to_be_visible()
+
+ order = [(entry["method"], entry["path"]) for entry in dm.dm_requests if entry["method"] in ("PUT", "POST")]
+ assert order.index(("PUT", "/settings")) < order.index(("POST", "/jobs")), order
+ job = dm.requests_to("POST", r"/jobs")[0]["body"]
+ assert job["operation"] == "backup" and job["backup_type"] == "full"
+ assert set(job["options"]) == {"include_cosmos", "include_ai_search", "include_source_blobs"}
+ expect(save_bar(page)).to_have_count(0)
+
+
+def test_leaving_with_unsaved_changes_asks_first(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ region(page, "Schedule").get_by_text("Scheduled backups", exact=True).click()
+ page.get_by_role("link", name=re.compile("^Chat", re.I)).first.click()
+ dialog = page.get_by_role("dialog", name="Leave Admin settings?")
+ expect(dialog).to_be_visible()
+ dialog.get_by_role("button", name="Stay").click()
+ expect(dialog).to_have_count(0)
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+
+
+@pytest.mark.parametrize("width", [390, 1920])
+def test_backup_recovery_never_overflows(dm, width):
+ if width == 390:
+ dm.add_job("migration-wide", operation="migration", status="completed", can_retry=False, can_cancel=False)
+ dm.open_backup_recovery(width=width)
+ page = dm.page
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ for test_id in (
+ "dm-readiness", "dm-backup-runs", "dm-schedule", "dm-storage", "dm-encryption",
+ "dm-migration", "dm-backup-inventory", "dm-cosmos-editor", "dm-jobs",
+ ):
+ assert_no_horizontal_overflow(page.get_by_test_id(test_id), test_id, width)
+ if width == 390:
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ assert_no_horizontal_overflow(inventory, "selected backup detail", width)
+ inventory.get_by_role("button", name="Restore…").click()
+ assert_no_horizontal_overflow(page.get_by_role("dialog", name="Restore backup"), "restore dialog", width)
+ page.get_by_role("dialog", name="Restore backup").get_by_role("button", name="Cancel").click()
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ job_detail = jobs.get_by_test_id("dm-job-detail")
+ assert_top_visible(job_detail, "job detail")
+ assert_no_horizontal_overflow(job_detail, "job detail", width)
+ dm.capture(f"responsive-{width}", ARTIFACTS)
+
+
+def section(page, section_id):
+ card = page.locator(f"#data-management-{section_id}-section")
+ card.scroll_into_view_if_needed()
+ return card
+
+
+def writes_to(dm, *paths):
+ """The ordered writes that hit any of the given API paths."""
+ return [write for write in dm.writes if write[1] in paths]
+
+
+def assert_no_horizontal_overflow(locator, label, width):
+ locator.scroll_into_view_if_needed()
+ overflow = locator.evaluate("element => element.scrollWidth - element.clientWidth")
+ assert overflow <= 1, f"{label} overflows by {overflow}px at {width}px"
+
+
+def assert_top_visible(locator, label):
+ box = locator.bounding_box()
+ assert box, f"{label} has no bounding box"
+ viewport_height = locator.page.evaluate("window.innerHeight")
+ assert 0 <= box["y"] <= viewport_height - 1, f"{label} top is outside the viewport: {box}"
+
+
+def unlock_cosmos(page):
+ editor = section(page, "cosmos-editor")
+ editor.get_by_role("button", name="Unlock editor").click()
+ unlock = page.get_by_role("dialog", name="Unlock Cosmos DB editor")
+ expect(unlock.get_by_role("button", name="Unlock", exact=True)).to_be_disabled()
+ unlock.get_by_role("checkbox").check()
+ unlock.get_by_role("button", name="Unlock", exact=True).click()
+ expect(editor.get_by_role("button", name="Lock editor")).to_be_visible()
+ return editor
+
+
+def walk_migration_to_confirm(migration, *, mirror=False):
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@")).check()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ if mirror:
+ migration.get_by_role("radio", name=re.compile("^Make destination match source")).check()
+ migration.get_by_role("checkbox", name=re.compile("Other writers to the destination AI Search are frozen")).check()
+ migration.get_by_role("button", name="Continue to Review").click()
+ migration.get_by_role("button", name="Run preflight review").click()
+ expect(migration.get_by_role("list", name="Review checks").get_by_text("Migration scope")).to_be_visible()
+ migration.get_by_role("button", name="Continue to Confirm").click()
+ migration.get_by_role("checkbox", name=re.compile("I reviewed the plan")).check()
+
+
+def test_looking_through_every_card_changes_nothing(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ for section_id in BACKUP_RECOVERY_SECTIONS["backup-recovery"]:
+ card = page.locator(f"#{section_id}")
+ card.scroll_into_view_if_needed()
+ for summary in card.locator("details > summary").all():
+ summary.click()
+ page.wait_for_timeout(300)
+ expect(save_bar(page)).to_have_count(0)
+ assert not dm.writes, dm.writes
+
+
+def test_inventory_summary_tiles_filter_with_server_queries(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("button", name=re.compile("^Full backups")).click()
+ expect(inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first).to_be_visible()
+ full_query = dm.requests_to("GET", r"/backups")[-1]["query"]
+ assert full_query["backup_type"] == ["full"]
+ assert full_query["status"] == ["available"]
+
+ inventory.get_by_role("button", name=re.compile("^Partial backups")).click()
+ expect(inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first).to_be_visible()
+ partial_query = dm.requests_to("GET", r"/backups")[-1]["query"]
+ assert partial_query["backup_type"] == ["partial"]
+
+ inventory.get_by_role("button", name=re.compile("^Available backups")).click()
+ expect(inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first).to_be_visible()
+ available_query = dm.requests_to("GET", r"/backups")[-1]["query"]
+ assert "backup_type" not in available_query
+ assert available_query["status"] == ["available"]
+
+
+def test_inventory_invalid_created_range_blocks_fetch(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ # The list loads once the card is on screen; count only after that load has landed.
+ expect(inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first).to_be_visible()
+ before = len(dm.requests_to("GET", r"/backups"))
+ inventory.get_by_label("Created from").fill("2026-09-10")
+ inventory.get_by_label("Created through").fill("2026-09-01")
+ expect(inventory.get_by_role("alert")).to_contain_text("end date must be on or after the start date")
+ page.wait_for_timeout(400)
+ assert len(dm.requests_to("GET", r"/backups")) == before
+
+
+def test_inventory_pages_with_continuation_tokens(dm):
+ dm.backups = [
+ backup_row(f"{index:08d}-1111-4111-8111-111111111111", "full" if index % 2 else "partial")
+ for index in range(1, 31)
+ ]
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ expect(inventory.get_by_role("button", name="Next")).to_be_enabled()
+ inventory.get_by_role("button", name="Next").click()
+ expect(inventory.get_by_text("Page 2")).to_be_visible()
+ assert dm.requests_to("GET", r"/backups")[-1]["query"]["continuation_token"] == ["25"]
+ inventory.get_by_role("button", name="Previous").click()
+ expect(inventory.get_by_text("Page 1")).to_be_visible()
+ assert "continuation_token" not in dm.requests_to("GET", r"/backups")[-1]["query"]
+
+
+def test_inventory_empty_second_page_steps_back_after_delete(dm):
+ dm.backups = [
+ backup_row(f"{index:08d}-1111-4111-8111-111111111111", "full")
+ for index in range(1, 27)
+ ]
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("button", name="Next").click()
+ expect(inventory.get_by_text("Page 2")).to_be_visible()
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Delete…").click()
+ page.get_by_role("dialog", name="Delete backup?").get_by_role("button", name="Delete backup").click()
+ expect(inventory.get_by_text("Page 1")).to_be_visible()
+ expect(inventory.get_by_role("list", name="Backup inventory").get_by_role("button")).to_have_count(25)
+
+
+def test_deleting_a_backup_refreshes_and_clears_selection(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ first_id = dm.backups[0]["id"]
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ expect(inventory).to_contain_text(first_id)
+ inventory.get_by_role("button", name="Delete…").click()
+ confirm = page.get_by_role("dialog", name="Delete backup?")
+ confirm.get_by_role("button", name="Delete backup").click()
+ expect(confirm).to_have_count(0)
+ expect(inventory.get_by_text("No backup selected")).to_be_visible()
+ assert dm.requests_to("DELETE", rf"/backups/{first_id}") or dm.requests_to("DELETE", r"/backups/.*")
+ assert first_id not in [backup["id"] for backup in dm.backups]
+ assert len(dm.requests_to("GET", r"/backups")) >= 2
+
+
+def test_deleting_backup_clears_matching_selected_job_detail(dm):
+ backup_id = dm.backups[0]["id"]
+ dm.add_job(backup_id, status="completed", can_retry=False, can_cancel=False)
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ expect(jobs.get_by_test_id("dm-job-detail")).to_contain_text(backup_id)
+
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Delete…").click()
+ page.get_by_role("dialog", name="Delete backup?").get_by_role("button", name="Delete backup").click()
+ jobs.scroll_into_view_if_needed()
+ expect(jobs.get_by_text("Select a job")).to_be_visible()
+
+
+def test_restore_destination_setup_opens_migration_target_step(dm):
+ dm.dm_settings.update({
+ "target_cosmos_endpoint": "",
+ "target_ai_search_endpoint": "",
+ "target_enhanced_citations_storage_blob_endpoint": "",
+ })
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ expect(dialog.get_by_role("button", name="Run review")).to_be_disabled()
+ dialog.get_by_role("button", name=re.compile("Set up the destination")).click()
+ expect(dialog).to_have_count(0)
+ migration = section(page, "migration")
+ expect(migration.get_by_role("heading", name="Connect the destination")).to_be_visible()
+
+
+def test_restore_review_requires_destinations_only_for_selected_surfaces(dm):
+ dm.dm_settings.update({
+ "target_cosmos_endpoint": "",
+ "target_ai_search_endpoint": "https://destination.search.windows.net",
+ "target_enhanced_citations_storage_blob_endpoint": "",
+ })
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+
+ expect(dialog.get_by_role("button", name="Run review")).to_be_disabled()
+ expect(dialog).to_contain_text("Cosmos DB and Enhanced Citation files are missing a destination")
+
+ dialog.get_by_role("checkbox", name="Cosmos DB").uncheck()
+ expect(dialog.get_by_role("button", name="Run review")).to_be_disabled()
+ expect(dialog).to_contain_text("Enhanced Citation files is missing a destination")
+
+ dialog.get_by_role("checkbox", name="Enhanced Citation files").uncheck()
+ expect(dialog.get_by_role("button", name="Run review")).to_be_enabled()
+ dialog.get_by_role("button", name="Run review").click()
+ expect(dialog.get_by_text("Backup manifest integrity")).to_be_visible()
+ plan = dm.requests_to("POST", r"/restore/review")[-1]["body"]["restore_plan"]
+ assert plan["include_cosmos"] is False
+ assert plan["include_ai_search"] is True
+ assert plan["include_source_blobs"] is False
+
+
+def test_restore_review_allows_ai_search_only_with_blank_cosmos_destination(dm):
+ dm.dm_settings.update({
+ "target_cosmos_endpoint": "",
+ "target_ai_search_endpoint": "https://destination.search.windows.net",
+ "target_enhanced_citations_storage_blob_endpoint": "",
+ })
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ dialog.get_by_role("checkbox", name="Cosmos DB").uncheck()
+ dialog.get_by_role("checkbox", name="Enhanced Citation files").uncheck()
+ expect(dialog.get_by_role("button", name="Run review")).to_be_enabled()
+ dialog.get_by_role("button", name="Run review").click()
+ expect(dialog.get_by_text("Backup manifest integrity")).to_be_visible()
+ assert dm.requests_to("POST", r"/restore/review")[-1]["body"]["restore_plan"]["include_cosmos"] is False
+
+
+def test_restore_dialog_create_only_copy_matches_collision_contract(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ expect(dialog).to_contain_text(
+ "Create only blocks the review when destination Cosmos DB containers or AI Search indexes already hold data."
+ )
+ expect(dialog).to_contain_text("existing source files are skipped as collisions")
+ expect(dialog.get_by_text("skipped and reported as collisions")).to_have_count(0)
+
+
+def test_restore_review_blockers_keep_queue_disabled(dm):
+ dm.restore_review = {
+ "ready": False,
+ "blocker_count": 1,
+ "warning_count": 0,
+ "checks": [{
+ "id": "destination",
+ "label": "Destination write check",
+ "status": "block",
+ "message": "Destination Cosmos DB cannot be written.",
+ }],
+ }
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ dialog.get_by_role("button", name="Run review").click()
+ expect(dialog.get_by_text("Destination write check")).to_be_visible()
+ expect(dialog.get_by_role("button", name="Queue restore")).to_be_disabled()
+
+
+def test_restore_review_saves_pending_backup_settings_first(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ section(page, "schedule").get_by_text("Scheduled backups", exact=True).click()
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ page.get_by_role("dialog", name="Restore backup").get_by_role("button", name="Run review").click()
+ expect(page.get_by_role("dialog", name="Restore backup").get_by_text("Backup manifest integrity")).to_be_visible()
+ order = [write[1].rsplit("/", 1)[-1] for write in dm.writes]
+ assert order[:2] == ["settings", "review"], order
+ expect(save_bar(page)).to_have_count(0)
+
+
+def test_restore_queue_500_warns_the_job_may_have_started(dm):
+ dm.queue_error = (500, {"success": False, "error": "Executor unavailable."})
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ dialog.get_by_role("button", name="Run review").click()
+ expect(dialog.get_by_text("Backup manifest integrity")).to_be_visible()
+ dialog.get_by_role("checkbox", name=re.compile("I reviewed the destination")).check()
+ dialog.get_by_role("button", name="Queue restore").click()
+ expect(dialog.get_by_text("may have been queued")).to_be_visible()
+ dm.errors[:] = [error for error in dm.errors if "status of 500" not in error]
+
+
+def test_restore_needs_a_current_review_an_acknowledgement_and_the_phrase(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ queue = dialog.get_by_role("button", name="Queue restore")
+ expect(queue).to_be_disabled()
+ dialog.get_by_role("radio", name=re.compile("^Overwrite existing")).check()
+ dialog.get_by_role("button", name="Run review").click()
+ expect(dialog.get_by_text("Backup manifest integrity")).to_be_visible()
+
+ review = dm.requests_to("POST", r"/restore/review")[-1]["body"]
+ assert review["restore_plan"]["restore_policy"] == "overwrite_existing"
+ assert set(review["settings"]) == set(EDITABLE_KEYS)
+
+ dialog.get_by_role("checkbox", name=re.compile("I reviewed the destination")).check()
+ expect(queue).to_be_disabled()
+ phrase = dialog.get_by_role("textbox")
+ phrase.fill("restore with overwrite")
+ expect(queue).to_be_disabled()
+ phrase.fill("RESTORE WITH OVERWRITE")
+ # The phrase is not part of what was reviewed, so typing it keeps the review current.
+ expect(dialog.get_by_text("Inputs changed after the review")).to_have_count(0)
+ expect(queue).to_be_enabled()
+ queue.click()
+ expect(dialog).to_have_count(0)
+
+ job = dm.requests_to("POST", r"/jobs")[-1]["body"]
+ assert job["operation"] == "restore"
+ assert job["options"]["review_authorization_token"] == "restore-token"
+ assert job["options"]["review_fingerprint"] == "restore-fingerprint"
+ plan = job["options"]["restore_plan"]
+ assert plan["source_backup_id"] == dm.backups[0]["id"]
+ assert plan["overwrite_confirmed"] is True and plan["overwrite_confirmation_phrase"] == "RESTORE WITH OVERWRITE"
+ expect(section(page, "jobs").get_by_test_id("dm-job-detail")).to_contain_text("Restore")
+
+
+def test_changing_the_restore_policy_makes_the_review_stale(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ inventory = section(page, "backup-inventory")
+ inventory.get_by_role("list", name="Backup inventory").get_by_role("button").first.click()
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ dialog.get_by_role("button", name="Run review").click()
+ dialog.get_by_role("checkbox", name=re.compile("I reviewed the destination")).check()
+ expect(dialog.get_by_role("button", name="Queue restore")).to_be_enabled()
+
+ dialog.get_by_role("checkbox", name="AI Search").uncheck()
+ expect(dialog.get_by_text("Inputs changed after the review. Run it again.")).to_be_visible()
+ expect(dialog.get_by_role("button", name="Queue restore")).to_be_disabled()
+ dialog.get_by_role("button", name="Cancel").click()
+
+ # Each opening starts again from a clean draft.
+ inventory.get_by_role("button", name="Restore…").click()
+ dialog = page.get_by_role("dialog", name="Restore backup")
+ expect(dialog.get_by_text("No restore review has run yet.")).to_be_visible()
+ expect(dialog.get_by_role("checkbox", name="AI Search")).to_be_checked()
+
+
+def test_retention_cleanup_saves_pending_settings_first(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ section(page, "schedule").locator("#data-management-retention-value").fill("21")
+ expect(save_bar(page)).to_contain_text("unsaved change")
+ section(page, "backup-inventory").get_by_role("button", name="Run retention cleanup").click()
+
+ confirm = page.get_by_role("dialog", name="Run retention cleanup?")
+ expect(confirm).to_contain_text("(21 days)")
+ assert writes_to(dm, "/api/admin/data-management/settings") == [("PUT", "/api/admin/data-management/settings")]
+ confirm.get_by_role("button", name="Run cleanup").click()
+ expect(page.get_by_text("Retention cleanup result")).to_be_visible()
+ order = [write[1].rsplit("/", 1)[-1] for write in dm.writes]
+ assert order == ["settings", "cleanup"], order
+ expect(save_bar(page)).to_have_count(0)
+
+
+def test_a_queued_job_is_followed_until_it_finishes(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ backup = section(page, "backup")
+ backup.get_by_role("button", name=re.compile("Queue full backup")).click()
+ backup.get_by_role("button", name="View in Job history").click()
+ detail = section(page, "jobs").get_by_test_id("dm-job-detail")
+ expect(detail).to_contain_text("Full backup")
+ # The live line names the finished state once polling has seen it.
+ expect(detail.get_by_text(re.compile(r"^Job is completed", re.I))).to_be_visible(timeout=15_000)
+ assert dm.progress_polls.get("backup-1", 0) >= 2
+ expect(detail.get_by_role("button", name="Cancel")).to_have_count(0)
+
+
+def test_cancelling_a_running_job_sends_the_reason(dm):
+ dm.add_job("backup-7", status="running", can_retry=False, can_cancel=True)
+ dm.progress_polls["backup-7"] = -1000
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ jobs.get_by_test_id("dm-job-detail").get_by_role("button", name="Cancel").click()
+ confirm = page.get_by_role("dialog", name="Request job cancellation")
+ confirm.get_by_role("textbox").fill("Maintenance window")
+ confirm.get_by_role("button", name="Request cancellation").click()
+ expect(confirm).to_have_count(0)
+ cancel = dm.requests_to("POST", r"/jobs/backup-7/cancel")
+ assert len(cancel) == 1 and cancel[0]["body"].get("reason") == "Maintenance window", cancel
+
+
+def test_retrying_a_job_never_saves_settings(dm):
+ dm.add_job("backup-9")
+ dm.open_backup_recovery()
+ page = dm.page
+ section(page, "schedule").get_by_text("Scheduled backups", exact=True).click()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ jobs.get_by_test_id("dm-job-detail").get_by_role("button", name="Retry failures").click()
+ expect(page.get_by_text(re.compile("retry queued"))).to_be_visible()
+ assert dm.writes == [("POST", "/api/admin/data-management/jobs/backup-9/retry")], dm.writes
+ # The pending edit is still pending, not saved behind the administrator's back.
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+
+
+def test_job_filters_map_to_history_query_params(dm):
+ dm.add_job("migration-done", operation="migration", status="completed", scheduled=True,
+ created_at="2026-09-03T09:00:00+00:00", can_retry=False)
+ dm.add_job("backup-failed", operation="backup", status="failed", scheduled=False,
+ created_at="2026-09-01T09:00:00+00:00")
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_label("Operation").select_option("migration")
+ jobs.get_by_label("Status").select_option("completed")
+ jobs.get_by_label("Run type").select_option("scheduled")
+ jobs.get_by_label("Rows per page").select_option("10")
+ jobs.get_by_label("Created from").fill("2026-09-02")
+ jobs.get_by_label("Created through").fill("2026-09-04")
+ expect(jobs.get_by_role("list", name="Jobs").get_by_text("Migration", exact=True)).to_be_visible()
+ query = dm.requests_to("GET", r"/jobs")[-1]["query"]
+ assert query["operation"] == ["migration"]
+ assert query["status"] == ["completed"]
+ assert query["scheduled"] == ["scheduled"]
+ assert query["page_size"] == ["10"]
+ assert query["created_from"] == ["2026-09-02"]
+ assert query["created_to"] == ["2026-09-04"]
+
+
+def test_job_invalid_created_range_blocks_fetch(dm):
+ dm.add_job("backup-older")
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ # The list loads once the card is on screen; count only after that load has landed.
+ expect(jobs.get_by_role("list", name="Jobs").get_by_role("button").first).to_be_visible()
+ before = len(dm.requests_to("GET", r"/jobs"))
+ jobs.get_by_label("Created from").fill("2026-09-10")
+ jobs.get_by_label("Created through").fill("2026-09-01")
+ expect(jobs.get_by_role("alert")).to_contain_text("end date must be on or after the start date")
+ page.wait_for_timeout(400)
+ assert len(dm.requests_to("GET", r"/jobs")) == before
+
+
+def test_jobs_page_with_continuation_tokens(dm):
+ for index in range(1, 31):
+ dm.add_job(f"backup-page-{index:02d}", status="completed", can_retry=False,
+ created_at=f"2026-09-{index:02d}T09:00:00+00:00")
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ expect(jobs.get_by_role("button", name="Next")).to_be_enabled()
+ jobs.get_by_role("button", name="Next").click()
+ expect(jobs.get_by_text("Page 2")).to_be_visible()
+ assert dm.requests_to("GET", r"/jobs")[-1]["query"]["continuation_token"] == ["25"]
+ jobs.get_by_role("button", name="Previous").click()
+ expect(jobs.get_by_text("Page 1")).to_be_visible()
+ assert "continuation_token" not in dm.requests_to("GET", r"/jobs")[-1]["query"]
+
+
+def test_jobs_empty_second_page_steps_back_on_refresh(dm):
+ for index in range(1, 27):
+ dm.add_job(f"backup-page-{index:02d}", status="completed", can_retry=False,
+ created_at=f"2026-09-{index:02d}T09:00:00+00:00")
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_role("button", name="Next").click()
+ expect(jobs.get_by_text("Page 2")).to_be_visible()
+ dm.jobs.pop("backup-page-01")
+ jobs.get_by_role("button", name="Refresh").click()
+ expect(jobs.get_by_text("Page 1")).to_be_visible()
+ expect(jobs.get_by_role("list", name="Jobs").get_by_role("button")).to_have_count(25)
+
+
+def test_finished_job_detail_does_not_fetch_loop(dm):
+ dm.add_job("idle-job", status="completed", can_retry=False, can_cancel=False)
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ expect(jobs.get_by_test_id("dm-job-detail")).to_contain_text("idle-job")
+ page.wait_for_timeout(3_200)
+ detail_requests = dm.requests_to("GET", r"/jobs/idle-job")
+ assert len(detail_requests) <= 2, detail_requests
+
+
+def test_migration_job_detail_exposes_manifest_download_links(dm):
+ dm.add_job("migration-artifacts", operation="migration", status="completed", can_retry=False, can_cancel=False)
+ dm.open_backup_recovery()
+ page = dm.page
+ jobs = section(page, "jobs")
+ jobs.get_by_role("list", name="Jobs").get_by_role("button").first.click()
+ detail = jobs.get_by_test_id("dm-job-detail")
+ expect(detail).to_contain_text("migration-artifacts")
+ manifest = detail.get_by_role("link", name="Download manifest")
+ failures = detail.get_by_role("link", name="Download failures")
+ expect(manifest).to_have_attribute("href", re.compile(r"/api/admin/data-management/jobs/migration-artifacts/migration-manifest$"))
+ expect(failures).to_have_attribute("href", re.compile(r"/api/admin/data-management/jobs/migration-artifacts/migration-manifest\?statuses=failed%2Cmissing%2Ccollision$"))
+
+
+def test_cosmos_editor_unlocks_queries_and_saves_with_the_phrase(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ editor = unlock_cosmos(page)
+
+ editor.get_by_label("Container").select_option("user_settings")
+ editor.get_by_role("button", name="Run query").click()
+ editor.get_by_test_id("dm-cosmos-editor-results").get_by_role("button").first.click()
+ json_box = editor.get_by_role("textbox", name="Cosmos DB document JSON")
+ expect(json_box).to_have_value(re.compile('"name": "Settings"'))
+ json_box.fill(json_box.input_value().replace('"Settings"', '"Repaired"'))
+ editor.get_by_role("button", name="Save…").click()
+
+ confirm = page.get_by_role("dialog", name="Confirm Cosmos DB document save")
+ save = confirm.get_by_role("button", name="Save document")
+ expect(save).to_be_disabled()
+ confirm.get_by_role("textbox").fill("I understand this can damage system data")
+ save.click()
+ expect(confirm).to_have_count(0)
+ assert dm.cosmos_documents["doc-1"]["name"] == "Repaired"
+ saved = dm.requests_to("PUT", r"/cosmos-editor/document")[-1]["body"]
+ assert saved["etag"] == "etag-1" and saved["confirmation_accepted"] is True
+
+
+def test_cosmos_document_detail_scrolls_into_view_on_mobile(dm):
+ dm.open_backup_recovery(width=390)
+ page = dm.page
+ editor = unlock_cosmos(page)
+ expect(editor.get_by_text("Cosmos DB editor unlocked for this page session.")).to_be_visible()
+ editor.get_by_label("Container").select_option("user_settings")
+ editor.get_by_role("button", name="Run query").click()
+ editor.get_by_test_id("dm-cosmos-editor-results").get_by_role("button").first.click()
+ detail = editor.get_by_role("textbox", name="Cosmos DB document JSON")
+ expect(detail).to_have_value(re.compile('"name": "Settings"'))
+ assert_top_visible(detail, "Cosmos document detail")
+
+
+def test_a_cosmos_conflict_asks_for_a_reload(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ editor = unlock_cosmos(page)
+ editor.get_by_label("Container").select_option("user_settings")
+ editor.get_by_role("button", name="Run query").click()
+ editor.get_by_test_id("dm-cosmos-editor-results").get_by_role("button").first.click()
+ json_box = editor.get_by_role("textbox", name="Cosmos DB document JSON")
+ expect(json_box).to_have_value(re.compile('"name": "Settings"'))
+ # Someone else saves the document after it was opened.
+ dm.cosmos_documents["doc-1"]["_etag"] = "etag-elsewhere"
+ json_box.fill(json_box.input_value().replace('"Settings"', '"Repaired"'))
+ editor.get_by_role("button", name="Save…").click()
+ confirm = page.get_by_role("dialog", name="Confirm Cosmos DB document save")
+ confirm.get_by_role("textbox").fill("I understand this can damage system data")
+ confirm.get_by_role("button", name="Save document").click()
+ expect(confirm.get_by_text(re.compile("changed after it was opened"))).to_be_visible()
+ expect(confirm.get_by_role("button", name="Reload")).to_be_visible()
+ assert dm.cosmos_documents["doc-1"]["name"] == "Settings"
+ dm.errors[:] = [error for error in dm.errors if "status of 409" not in error]
+
+
+def test_migration_reviews_then_queues_with_its_authorization(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ walk_migration_to_confirm(migration, mirror=True)
+
+ start = migration.get_by_role("button", name="Start migration")
+ expect(start).to_be_disabled()
+ migration.get_by_role("textbox").fill("MAKE DESTINATION MATCH SOURCE")
+ expect(start).to_be_enabled()
+ start.click()
+ expect(migration.get_by_text("migration-1", exact=True)).to_be_visible()
+
+ review = dm.requests_to("POST", r"/migration/review")[-1]["body"]
+ job = dm.requests_to("POST", r"/jobs")[-1]["body"]
+ assert job["operation"] == "migration"
+ assert job["options"]["review_fingerprint"] == "migration-fingerprint"
+ assert job["options"]["review_authorization_token"] == "migration-token"
+ plan = job["options"]["migration_plan"]
+ assert plan["migration_mode"] == "mirror_with_deletions"
+ assert plan["mirror_confirmation"] == "MAKE DESTINATION MATCH SOURCE"
+ # The phrase authorizes the run; everything the review covered is sent unchanged.
+ assert review["migration_plan"]["mirror_confirmation"] == ""
+ assert {key: value for key, value in plan.items() if key != "mirror_confirmation"} == {
+ key: value for key, value in review["migration_plan"].items() if key != "mirror_confirmation"
+ }
+
+
+def test_a_stale_migration_review_sends_the_administrator_back(dm):
+ dm.queue_error = (409, {"success": False, "error": "Migration inputs changed after preflight review. Run review again before execution.", "workflow_step": "review"})
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ walk_migration_to_confirm(migration)
+ migration.get_by_role("button", name="Start migration").click()
+ expect(migration.get_by_role("heading", name="Review migration evidence")).to_be_visible()
+ expect(migration.get_by_text(re.compile("Run review again before execution"))).to_be_visible()
+ dm.errors[:] = [error for error in dm.errors if "status of 409" not in error]
+
+
+def test_a_typed_destination_key_does_not_make_the_review_stale(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_label("Account key", exact=True).fill("new-destination-key")
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+ walk_migration_to_confirm(migration)
+
+ start = migration.get_by_role("button", name="Save and start migration")
+ start.click()
+ expect(migration.get_by_text("migration-1", exact=True)).to_be_visible()
+ order = [write[1].rsplit("/", 1)[-1] for write in dm.writes]
+ assert order == ["review", "settings", "jobs"], order
+ saved = dm.requests_to("PUT", r"/settings")[-1]["body"]
+ assert saved["target_cosmos_key"] == "new-destination-key"
+ expect(save_bar(page)).to_have_count(0)
+
+
+def test_migration_destination_tests_post_inline_settings_without_saving(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.locator("[data-dm-key='target_cosmos_endpoint']").get_by_label("Endpoint").fill("https://inline.documents.azure.com:443/")
+ migration.get_by_role("button", name="Test Cosmos DB").click()
+ expect(migration.get_by_text("planned containers verified")).to_be_visible()
+ cosmos_body = dm.requests_to("POST", r"/target/cosmos/test")[-1]["body"]
+ assert cosmos_body["settings"]["target_cosmos_endpoint"] == "https://inline.documents.azure.com:443/"
+
+ migration.locator("[data-dm-key='target_ai_search_endpoint']").get_by_label("Endpoint").fill("https://inline.search.windows.net")
+ migration.get_by_role("button", name="Test AI Search").click()
+ expect(migration.get_by_text("expected indexes exist")).to_be_visible()
+ search_body = dm.requests_to("POST", r"/target/search/test")[-1]["body"]
+ assert search_body["settings"]["target_ai_search_endpoint"] == "https://inline.search.windows.net"
+
+ migration.locator("[data-dm-key='target_enhanced_citations_storage_blob_endpoint']").get_by_label("Blob endpoint").fill("https://inline.blob.core.windows.net")
+ migration.get_by_role("button", name="Test storage").click()
+ expect(migration.get_by_text("containers ready")).to_be_visible()
+ storage_body = dm.requests_to("POST", r"/target/enhanced-citation-storage/test")[-1]["body"]
+ assert storage_body["settings"]["target_enhanced_citations_storage_blob_endpoint"] == "https://inline.blob.core.windows.net"
+ assert not dm.requests_to("PUT", r"/settings")
+ expect(save_bar(page)).to_contain_text("3 unsaved changes")
+
+
+def test_enabling_destination_ru_boost_reveals_management_fields(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^All")).first.check()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ expect(migration.get_by_label("Subscription ID")).to_have_count(0)
+ migration.get_by_text("Destination RU Boost for this migration", exact=True).click()
+ expect(migration.get_by_label("Subscription ID")).to_be_visible()
+ expect(migration.get_by_label("Resource group")).to_be_visible()
+
+
+def test_invalid_previous_migration_guid_blocks_options_continue(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^All")).first.check()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ migration.get_by_role("radio", name=re.compile("^Catch up changed items")).check()
+ migration.get_by_label("Previous migration job ID").fill("not-a-guid")
+ expect(migration.get_by_text("must be a GUID")).to_be_visible()
+ expect(migration.get_by_role("button", name="Continue to Review")).to_be_disabled()
+
+
+def test_migration_all_scope_counts_once_and_uses_count_in_header(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^All")).check()
+ expect(migration.get_by_role("tab").filter(has_text="Users").filter(has_text="30")).to_be_visible()
+ expect(migration.get_by_text("30 users included.")).to_be_visible()
+ expect(migration.get_by_text("30 principal scopes selected")).to_be_visible()
+
+ count_requests = dm.requests_to("GET", r"/migration/catalog/users")
+ assert len(count_requests) == 1, count_requests
+ assert count_requests[0]["query"] == {
+ "search": [""],
+ "continuation_token": [""],
+ "page_size": ["1"],
+ }
+
+
+def test_migration_all_scope_count_failure_waits_for_retry(dm):
+ dm.catalog_count_failures = 1
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^All")).check()
+ expect(migration.get_by_role("tab").filter(has_text="Users").filter(has_text="Unavailable")).to_be_visible()
+ expect(migration.get_by_text("The users count could not be loaded. The server review still counts them.")).to_be_visible()
+ expect(migration.get_by_text("Some counts are unavailable")).to_be_visible()
+ page.wait_for_timeout(2_100)
+ count_requests = dm.requests_to("GET", r"/migration/catalog/users")
+ assert len(count_requests) == 1, count_requests
+
+ migration.get_by_role("button", name="Retry count").click()
+ expect(migration.get_by_text("30 users included.")).to_be_visible()
+ assert len(dm.requests_to("GET", r"/migration/catalog/users")) == 2
+ dm.errors[:] = [error for error in dm.errors if "status of 503" not in error]
+
+
+def test_migration_zero_all_count_blocks_scope_step(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("tab", name=re.compile("^Public workspaces")).click()
+ migration.get_by_role("radio", name=re.compile("^All")).check()
+ expect(migration.get_by_text("0 public workspaces included.")).to_be_visible()
+ expect(migration.get_by_text("Choose at least one user, group, or public workspace before review.")).to_be_visible()
+ expect(migration.get_by_role("button", name="Continue to What moves")).to_be_disabled()
+
+
+def test_migration_catalog_empty_state_changes_after_search(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ expect(migration.get_by_text("Search the server catalog.")).to_be_visible()
+ migration.get_by_label(re.compile("Search users")).fill("does-not-exist")
+ migration.get_by_role("button", name="Search", exact=True).click()
+ expect(migration.get_by_text("No matches found.")).to_be_visible()
+
+
+def test_migration_confirm_uses_server_normalized_review_summary(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_text("Include their documents", exact=True).click()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@")).check()
+ migration.get_by_role("tab", name=re.compile("^Groups")).click()
+ migration.get_by_role("radio", name=re.compile("^All")).check()
+ migration.get_by_text("Include their documents", exact=True).click()
+ expect(migration.get_by_text("1 groups included.")).to_be_visible()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ migration.get_by_role("radio", name=re.compile("^Make destination match source")).check()
+ migration.get_by_role("checkbox", name=re.compile("Other writers to the destination AI Search are frozen")).check()
+ migration.get_by_role("button", name="Continue to Review").click()
+ migration.get_by_role("button", name="Run preflight review").click()
+ expect(migration.get_by_text("Users: Selected (1, 1 documents, documents included)")).to_be_visible()
+ expect(migration.get_by_text("Groups: All (1, 4 documents, documents included)")).to_be_visible()
+ migration.get_by_role("button", name="Continue to Confirm").click()
+
+ plan = migration.get_by_text("Server-normalized plan").locator("xpath=ancestor::div[contains(@class, 'rounded-xl')][1]")
+ expect(plan).to_contain_text("Ready")
+ expect(plan).to_contain_text("Principal scopes")
+ expect(plan).to_contain_text("2")
+ expect(plan).to_contain_text("Included documents")
+ expect(plan).to_contain_text("5")
+ expect(plan).to_contain_text("Synchronization mode")
+ expect(plan).to_contain_text("Make destination match source")
+ expect(plan).to_contain_text("Creates / updates")
+ expect(plan).to_contain_text("3 / 2")
+ expect(plan).to_contain_text("Deletes")
+ expect(plan).to_contain_text("1")
+ expect(plan).to_contain_text("Conflicts")
+ expect(plan).to_contain_text("4")
+
+
+def test_migration_validate_cosmos_access_uses_data_copy(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@")).check()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ migration.get_by_role("checkbox", name=re.compile("Other writers to the destination AI Search are frozen")).check()
+ migration.get_by_role("button", name="Continue to Review").click()
+ migration.get_by_role("button", name="Validate Cosmos access").click()
+ expect(migration.get_by_text(
+ "Cosmos data-copy access is ready. 7 planned Cosmos containers can be read and written. RU Boost permissions are tested separately."
+ )).to_be_visible()
+
+
+def test_migration_catalog_paging_keeps_selections_across_pages_and_tabs(dm):
+ dm.catalog["groups"] = [
+ {"id": f"group-{index}", "label": f"Group {index}", "description": f"group-{index}", "document_count": index}
+ for index in range(1, 28)
+ ]
+ dm.catalog["users"] = [
+ {"id": f"user-{index}", "label": f"User {index}", "description": f"user{index}@contoso.com", "document_count": index}
+ for index in range(1, 61)
+ ]
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@")).check()
+ migration.get_by_role("button", name="Next").click()
+ expect(migration.get_by_text("User 26")).to_be_visible()
+ assert dm.requests_to("GET", r"/migration/catalog/users")[-1]["query"]["continuation_token"] == ["25"]
+ migration.get_by_role("checkbox", name=re.compile(r"^User 26 user26@")).check()
+ migration.get_by_role("button", name="Previous").click()
+ expect(migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@"))).to_be_checked()
+ migration.get_by_role("tab", name=re.compile("^Groups")).click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile("^Group 1 group-1")).check()
+ migration.get_by_role("tab", name=re.compile("^Users")).click()
+ expect(migration.get_by_text("Selected (2)")).to_be_visible()
+ expect(migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@"))).to_be_checked()
+
+
+def test_migration_review_blocker_locks_confirm_and_go_to_step_opens_fix(dm):
+ dm.migration_review = {
+ "ready": False,
+ "blocker_count": 1,
+ "warning_count": 0,
+ "checks": [{
+ "id": "target",
+ "label": "Destination configuration",
+ "status": "block",
+ "summary": "Destination Cosmos DB is not writable.",
+ "workflow_step": "target",
+ }],
+ }
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^Selected")).check()
+ migration.get_by_role("button", name="Search", exact=True).click()
+ migration.get_by_role("checkbox", name=re.compile(r"^User 1 user1@")).check()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ migration.get_by_role("checkbox", name=re.compile("Other writers to the destination AI Search are frozen")).check()
+ migration.get_by_role("button", name="Continue to Review").click()
+ migration.get_by_role("button", name="Run preflight review").click()
+ expect(migration.get_by_text("Destination configuration")).to_be_visible()
+ expect(migration.get_by_role("button", name="Continue to Confirm")).to_be_disabled()
+ migration.get_by_role("button", name="Go to Destination").click()
+ expect(migration.get_by_role("heading", name="Connect the destination")).to_be_visible()
+
+
+def test_running_migration_job_reattaches_progress_when_card_opens(dm):
+ dm.add_job("migration-live", operation="migration", status="running", can_retry=False, can_cancel=True,
+ progress={"current_step": "copy", "percent_complete": 25, "completed_steps": 1, "total_steps": 4})
+ dm.progress_polls["migration-live"] = -1000
+ dm.open_backup_recovery()
+ page = dm.page
+ migration = section(page, "migration")
+ expect(migration.get_by_role("heading", name="Monitor migration progress")).to_be_visible()
+ expect(migration.get_by_text("migration-live")).to_be_visible()
+
+
+def test_a_failed_container_list_waits_for_the_administrator(dm):
+ dm.cosmos_container_failures = 1
+ dm.open_backup_recovery()
+ page = dm.page
+ editor = unlock_cosmos(page)
+ expect(editor.get_by_text("The container list is not loaded.")).to_be_visible()
+ page.wait_for_timeout(1_500)
+ assert len(dm.requests_to("GET", r"/cosmos-editor/containers")) == 1
+ editor.get_by_role("button", name="Load containers").click()
+ expect(editor.get_by_label("Container").locator("option[value='user_settings']")).to_have_count(1)
+ expect(editor.get_by_text("The container list is not loaded.")).to_have_count(0)
+ dm.errors[:] = [error for error in dm.errors if "status of 503" not in error]
+
+
+def test_editing_a_query_drops_its_next_page(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ editor = unlock_cosmos(page)
+ editor.get_by_label("Container").select_option("user_settings")
+ query = editor.get_by_label("SELECT query")
+ query.fill("SELECT * FROM c")
+ editor.get_by_role("button", name="Run query").click()
+ expect(editor.get_by_role("button", name="Next page")).to_be_visible()
+ query.fill("SELECT c.id FROM c")
+ expect(editor.get_by_role("button", name="Next page")).to_have_count(0)
+
+
+def test_storage_test_posts_unsaved_values_without_saving_settings(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ storage = section(page, "storage")
+ storage.get_by_label("Blob endpoint").fill("https://unsaved.blob.core.windows.net")
+ storage.get_by_label("Container").fill("unsaved-container")
+ expect(save_bar(page)).to_contain_text("2 unsaved changes")
+ storage.get_by_role("button", name="Test storage").click()
+ expect(storage.get_by_text("Connected. Container unsaved-container was found.")).to_be_visible()
+ body = dm.requests_to("POST", r"/storage/test")[-1]["body"]
+ assert body["settings"]["backup_storage_blob_endpoint"] == "https://unsaved.blob.core.windows.net"
+ assert body["settings"]["backup_storage_container_name"] == "unsaved-container"
+ assert not dm.requests_to("PUT", r"/settings")
+ expect(save_bar(page)).to_contain_text("2 unsaved changes")
+
+
+def test_generating_and_replacing_key_without_pending_changes_only_posts_key(dm):
+ dm.dm_settings.update({"encryption_key_reference": "", "encryption_key_storage": ""})
+ dm.open_backup_recovery()
+ page = dm.page
+ encryption = section(page, "encryption")
+ encryption.get_by_role("button", name="Generate key").click()
+ expect(encryption.get_by_text("Generated (hidden)")).to_be_visible()
+ assert dm.writes == [("POST", "/api/admin/data-management/encryption-key")]
+
+ dm.writes.clear()
+ encryption.get_by_role("button", name="Replace key").click()
+ confirm = page.get_by_role("dialog", name="Replace backup encryption key?")
+ expect(confirm).to_be_visible()
+ confirm.get_by_role("button", name="Replace key").click()
+ expect(confirm).to_have_count(0)
+ assert dm.writes == [("POST", "/api/admin/data-management/encryption-key")]
+
+
+def test_generating_a_key_waits_for_unsaved_key_vault_settings(page):
+ sections = {"security": ("keyvault-section",), **BACKUP_RECOVERY_SECTIONS}
+ dm = DataManagementFixture(page, sections=sections)
+ page.emulate_media(reduced_motion="reduce")
+ dm.open_backup_recovery()
+ vault = page.locator("#keyvault-section")
+ vault.scroll_into_view_if_needed()
+ vault.get_by_text("Vault connection", exact=True).click()
+ vault.get_by_text("Store agent and action secrets in Key Vault", exact=True).first.click()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+
+ section(page, "encryption").get_by_role("button", name="Replace key").click()
+ page.get_by_role("dialog", name="Save all changes first?").get_by_role("button", name="Save all and continue").click()
+ replace = page.get_by_role("dialog", name="Replace backup encryption key?")
+ replace.get_by_role("button", name="Replace key").click()
+ expect(replace).to_have_count(0)
+ assert dm.writes == [
+ ("PATCH", "/api/v2/admin/settings"),
+ ("POST", "/api/admin/data-management/encryption-key"),
+ ], dm.writes
+ expect(save_bar(page)).to_have_count(0)
+ dm.assert_clean()
+
+
+def test_switching_categories_keeps_edits_and_invents_none(page):
+ sections = {
+ "chat": ("enhanced-citations-section",),
+ "security": ("keyvault-section",),
+ **BACKUP_RECOVERY_SECTIONS,
+ }
+ dm = DataManagementFixture(page, sections=sections)
+ page.emulate_media(reduced_motion="reduce")
+ dm.open_backup_recovery()
+ rail = page.get_by_role("complementary", name="Settings categories")
+
+ for label in ("Backup & Recovery", "Chat", "Security", "All settings", "Backup & Recovery"):
+ rail.get_by_role("button", name=re.compile(f"^{re.escape(label)}$")).click()
+ expect(rail.get_by_role("button", name=re.compile(f"^{re.escape(label)}$"))).to_have_attribute("aria-pressed", "true")
+ expect(save_bar(page)).to_have_count(0)
+
+ # A pending backup edit lives in the store, not the card, so hiding the card keeps it.
+ section(page, "schedule").get_by_text("Scheduled backups", exact=True).click()
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+ rail.get_by_role("button", name=re.compile("^Chat$")).click()
+ expect(page.locator("#data-management-schedule-section")).to_have_count(0)
+ expect(save_bar(page)).to_contain_text("1 unsaved change")
+ rail.get_by_role("button", name=re.compile(f"^{re.escape('Backup & Recovery')}$")).click()
+ expect(section(page, "schedule").get_by_role("checkbox", name=re.compile("^Scheduled backups"))).to_be_checked()
+ assert not dm.writes, dm.writes
+ dm.assert_clean()
+
+
+def test_backup_storage_waits_for_unsaved_enhanced_citation_settings(page):
+ sections = {"chat": ("enhanced-citations-section",), **BACKUP_RECOVERY_SECTIONS}
+ dm = DataManagementFixture(page, sections=sections)
+ page.emulate_media(reduced_motion="reduce")
+ dm.open_backup_recovery()
+ citations = page.locator("#enhanced-citations-section")
+ citations.scroll_into_view_if_needed()
+ citations.get_by_text("Enable Enhanced Citations", exact=True).first.click()
+ section(page, "schedule").get_by_text("Scheduled backups", exact=True).click()
+ expect(save_bar(page)).to_contain_text("2 unsaved changes")
+
+ section(page, "backup").get_by_role("button", name=re.compile("Save and queue full backup")).click()
+ confirm = page.get_by_role("dialog", name="Save all changes first?")
+ expect(confirm).to_contain_text("Enhanced Citations storage settings have unsaved changes")
+ assert not dm.writes, "nothing is saved until the administrator agrees"
+ confirm.get_by_role("button", name="Save all and continue").click()
+ expect(section(page, "backup").get_by_text("Full backup queued")).to_be_visible()
+ assert dm.writes[:3] == [
+ ("PATCH", "/api/v2/admin/settings"),
+ ("PUT", "/api/admin/data-management/settings"),
+ ("POST", "/api/admin/data-management/jobs"),
+ ], dm.writes
+ expect(save_bar(page)).to_have_count(0)
+ dm.assert_clean()
+
+
+def test_start_here_open_storage_and_settings_search_keywords(dm):
+ dm.open_backup_recovery(width=1600)
+ page = dm.page
+ readiness = section(page, "readiness")
+ storage_item = readiness.get_by_role("listitem").filter(has_text="Backup storage")
+ storage_item.get_by_role("button", name="Open Backup storage").click()
+ expect(section(page, "storage")).to_be_in_viewport()
+
+ search = page.get_by_role("searchbox", name="Search settings")
+ search.fill("retention")
+ expect(region(page, "Schedule")).to_be_visible()
+ expect(region(page, "Cosmos Editor")).to_have_count(0)
+ search.fill("cosmos editor")
+ expect(region(page, "Cosmos Editor")).to_be_visible()
+ expect(region(page, "Schedule")).to_have_count(0)
+
+
+def test_minor_backup_recovery_copy_updates_are_present(dm):
+ dm.open_backup_recovery()
+ page = dm.page
+ expect(section(page, "schedule")).to_contain_text("The newest successful full backup is kept by default")
+ expect(section(page, "storage")).to_contain_text(
+ "dedicated-storage check compares against the saved Enhanced Citations settings"
+ )
+ section(page, "backup-inventory").get_by_role("button", name="Run retention cleanup").click()
+ expect(page.get_by_role("dialog", name="Run retention cleanup?")).to_contain_text(
+ "The newest successful full backup is kept by default"
+ )
+ page.get_by_role("dialog", name="Run retention cleanup?").get_by_role("button", name="Cancel").click()
+ migration = section(page, "migration")
+ migration.get_by_role("button", name="Continue to Scope").click()
+ migration.get_by_role("radio", name=re.compile("^All")).check()
+ expect(migration.get_by_text("30 users included.")).to_be_visible()
+ migration.get_by_role("button", name="Continue to What moves").click()
+ migration.get_by_role("button", name="About RU Boost").click()
+ expect(page.get_by_role("dialog", name="RU Boost permissions")).to_contain_text(
+ "Use Test RU Boost before a cutover window"
+ )
+ page.get_by_role("dialog", name="RU Boost permissions").get_by_text("Close", exact=True).click()
+
+
+def test_on_this_page_index_shows_backup_recovery_statuses(dm):
+ dm.open_backup_recovery(width=1920)
+ page = dm.page
+ index = page.get_by_role("navigation", name="On this page")
+ storage = index.get_by_role("link", name=re.compile("^Storage"))
+ schedule = index.get_by_role("link", name=re.compile("^Schedule"))
+ expect(storage).to_contain_text("Storage")
+ expect(storage.locator(".sr-only")).to_have_text("Configured")
+ expect(schedule.locator(".sr-only")).to_have_text("Off")
+
+
+def test_inventory_and_job_rows_support_arrow_key_focus(dm):
+ for index in range(1, 4):
+ dm.backups.append(backup_row(f"33333333-3333-4333-8333-33333333333{index}", "full"))
+ dm.add_job(f"keyboard-job-{index}", status="completed", can_retry=False)
+ dm.open_backup_recovery()
+ page = dm.page
+
+ inventory = section(page, "backup-inventory")
+ backup_rows = inventory.get_by_role("list", name="Backup inventory").get_by_role("button")
+ backup_rows.nth(0).focus()
+ page.keyboard.press("ArrowDown")
+ expect(backup_rows.nth(1)).to_be_focused()
+ page.keyboard.press("ArrowUp")
+ expect(backup_rows.nth(0)).to_be_focused()
+
+ jobs = section(page, "jobs")
+ job_rows = jobs.get_by_role("list", name="Jobs").get_by_role("button")
+ job_rows.nth(0).focus()
+ page.keyboard.press("ArrowDown")
+ expect(job_rows.nth(1)).to_be_focused()
+ page.keyboard.press("ArrowUp")
+ expect(job_rows.nth(0)).to_be_focused()