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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {deleteTarget && (
+
setDeleteTarget(null)}
+ onConfirm={handleDelete}
+ title="Delete Target List"
+ message={`Delete "${deleteTarget.name}"? This cannot be undone.`}
+ confirmLabel="Delete"
+ confirmVariant="destructive"
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 29b391ad..00e61ed7 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -46,6 +46,7 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
+const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
export const router = createBrowserRouter([
{
@@ -337,6 +338,14 @@ export const router = createBrowserRouter([
),
},
+ {
+ path: 'target-lists',
+ element: (
+ }>
+
+
+ ),
+ },
],
},
],