diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index c7755452..a15b5bc5 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, RefreshCw } from 'lucide-react' +import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw } from 'lucide-react' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' import { cn } from '@/lib/utils' @@ -496,6 +496,23 @@ export function AccountSettingsPage() { )} + {/* Target Lists Link (owners only) */} + {isAccountOwner && ( + +
+ +
+

Target Lists

+

Saved server lists for maintenance flow batch launching

+
+
+ + + )} + {/* Preferences Section */}
diff --git a/frontend/src/pages/account/TargetListsPage.tsx b/frontend/src/pages/account/TargetListsPage.tsx new file mode 100644 index 00000000..a0cf025a --- /dev/null +++ b/frontend/src/pages/account/TargetListsPage.tsx @@ -0,0 +1,251 @@ +import { useEffect, useState } from 'react' +import { Plus, Pencil, Trash2, Server } from 'lucide-react' +import { targetListsApi } from '@/api' +import type { TargetList, TargetListCreate, TargetEntry } from '@/types' +import { toast } from '@/lib/toast' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' + +export default function TargetListsPage() { + const [lists, setLists] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [showEditor, setShowEditor] = useState(false) + const [editingList, setEditingList] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + // Editor state + const [editorName, setEditorName] = useState('') + const [editorDescription, setEditorDescription] = useState('') + const [editorTargets, setEditorTargets] = useState('') + const [isSaving, setIsSaving] = useState(false) + + const load = async () => { + try { + const data = await targetListsApi.list() + setLists(data) + } catch { + toast.error('Failed to load target lists') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { load() }, []) + + const openEditor = (list?: TargetList) => { + if (list) { + setEditingList(list) + setEditorName(list.name) + setEditorDescription(list.description ?? '') + setEditorTargets(list.targets.map(t => t.notes ? `${t.label} # ${t.notes}` : t.label).join('\n')) + } else { + setEditingList(null) + setEditorName('') + setEditorDescription('') + setEditorTargets('') + } + setShowEditor(true) + } + + const parseTargets = (raw: string): TargetEntry[] => + raw.split('\n') + .map(l => l.trim()) + .filter(Boolean) + .map(line => { + const hashIdx = line.indexOf('#') + if (hashIdx === -1) return { label: line } + return { + label: line.slice(0, hashIdx).trim(), + notes: line.slice(hashIdx + 1).trim() || undefined, + } + }) + + const handleSave = async () => { + if (!editorName.trim()) { toast.error('Name is required'); return } + const targets = parseTargets(editorTargets) + if (targets.length === 0) { toast.error('Add at least one target'); return } + + setIsSaving(true) + try { + const payload: TargetListCreate = { + name: editorName.trim(), + description: editorDescription.trim() || undefined, + targets, + } + if (editingList) { + const updated = await targetListsApi.update(editingList.id, payload) + setLists(prev => prev.map(l => l.id === updated.id ? updated : l)) + toast.success('Target list updated') + } else { + const created = await targetListsApi.create(payload) + setLists(prev => [...prev, created]) + toast.success('Target list created') + } + setShowEditor(false) + } catch { + toast.error('Failed to save target list') + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + try { + await targetListsApi.delete(deleteTarget.id) + setLists(prev => prev.filter(l => l.id !== deleteTarget.id)) + toast.success('Target list deleted') + } catch { + toast.error('Failed to delete target list') + } finally { + setDeleteTarget(null) + } + } + + return ( +
+
+
+

Target Lists

+

+ Saved server lists for maintenance flow batch launching +

+
+ +
+ + {isLoading ? ( +
+
+
+ ) : lists.length === 0 ? ( +
+ +

No target lists yet

+

+ Create lists of servers to reuse across maintenance runs +

+
+ ) : ( +
+ {lists.map(list => ( +
+
+

{list.name}

+ {list.description && ( +

{list.description}

+ )} +

+ {list.targets.length} target{list.targets.length !== 1 ? 's' : ''} + {list.targets.length > 0 && ( + <> · {list.targets.slice(0, 3).map(t => t.label).join(', ')}{list.targets.length > 3 ? '\u2026' : ''} + )} +

+
+
+ + +
+
+ ))} +
+ )} + + {/* Editor Modal */} + {showEditor && ( +
+
+

+ {editingList ? 'Edit Target List' : 'New Target List'} +

+
+
+ + setEditorName(e.target.value)} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + placeholder="e.g. RDS Farm A" + /> +
+
+ + setEditorDescription(e.target.value)} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + placeholder="e.g. Production RDS servers" + /> +
+
+ +