feat: AI flow builder, visibility model, dashboard tabs, fork UI (#88)

- AI flow builder: scaffold → branch detail → assemble → review flow
- Generate All one-click branch generation with stop/cancel
- Regenerate scaffold suggestions button
- 3-action review screen: Start Flow, Open in Editor, Build Another
- Fix Publish button gated behind !isDirty
- Fix visibility column enforcement in tree access filter
- Add ?visibility filter and author_name to GET /trees
- Dashboard tabbed flows: My Flows / My Team / Public / All
- Create button in My Flows tab, window focus reload (stale data fix)
- Fork UI with optional reason modal
- Fix account_id nullability in User type and schema
- Keep is_public and visibility in sync on updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #88.
This commit is contained in:
chihlasm
2026-02-24 07:40:44 -05:00
committed by GitHub
parent 97cd297f46
commit ed4ab059bf
41 changed files with 1909 additions and 315 deletions

View File

@@ -4,6 +4,10 @@ import { targetListsApi } from '@/api'
import type { TargetList, TargetListCreate, TargetEntry } from '@/types'
import { toast } from '@/lib/toast'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { Modal } from '@/components/common/Modal'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { PageHeader } from '@/components/common/PageHeader'
export default function TargetListsPage() {
const [lists, setLists] = useState<TargetList[]>([])
@@ -103,34 +107,31 @@ export default function TargetListsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-foreground">Target Lists</h1>
<p className="text-[0.8125rem] text-muted-foreground">
Saved server lists for maintenance flow batch launching
</p>
</div>
<button
onClick={() => openEditor()}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
<Plus className="h-4 w-4" />
New List
</button>
</div>
<PageHeader
title="Target Lists"
titleClassName="text-xl font-semibold"
description="Saved server lists for maintenance flow batch launching"
action={(
<button
onClick={() => openEditor()}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
<Plus className="h-4 w-4" />
New List
</button>
)}
/>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<Spinner size="sm" className="h-5 w-5 border-primary border-t-transparent" />
</div>
) : lists.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-12 text-center">
<Server className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium text-foreground">No target lists yet</p>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Create lists of servers to reuse across maintenance runs
</p>
</div>
<EmptyState
icon={<Server className="h-10 w-10" />}
title="No target lists yet"
description="Create lists of servers to reuse across maintenance runs."
/>
) : (
<div className="space-y-3">
{lists.map(list => (
@@ -172,68 +173,68 @@ export default function TargetListsPage() {
)}
{/* Editor Modal */}
{showEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl">
<h2 className="mb-4 text-base font-semibold text-foreground">
{editingList ? 'Edit Target List' : 'New Target List'}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Name
</label>
<input
type="text"
value={editorName}
onChange={e => 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"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Description (optional)
</label>
<input
type="text"
value={editorDescription}
onChange={e => 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"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Targets &mdash; one per line (add notes after #)
</label>
<textarea
value={editorTargets}
onChange={e => setEditorTargets(e.target.value)}
rows={6}
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={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setShowEditor(false)}
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
{isSaving ? 'Saving\u2026' : 'Save'}
</button>
</div>
<Modal
isOpen={showEditor}
onClose={() => setShowEditor(false)}
title={editingList ? 'Edit Target List' : 'New Target List'}
size="md"
footer={(
<div className="flex justify-end gap-2">
<button
onClick={() => setShowEditor(false)}
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
{isSaving ? 'Saving\u2026' : 'Save'}
</button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Name
</label>
<input
type="text"
value={editorName}
onChange={e => 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"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Description (optional)
</label>
<input
type="text"
value={editorDescription}
onChange={e => 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"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Targets &mdash; one per line (add notes after #)
</label>
<textarea
value={editorTargets}
onChange={e => setEditorTargets(e.target.value)}
rows={6}
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={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
/>
</div>
</div>
)}
</Modal>
{deleteTarget && (
<ConfirmDialog

View File

@@ -3,6 +3,7 @@ import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
import { PageHeader } from '@/components/common/PageHeader'
import api from '@/api/client'
interface TeamCategory {
@@ -80,17 +81,17 @@ export function TeamCategoriesPage() {
const inputCn = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20')
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading text-foreground">Team Categories</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>
<Plus className="h-4 w-4" />
Create Category
</button>
</div>
<div className="space-y-6">
<PageHeader
title="Team Categories"
description="Manage tree categories for your team"
action={(
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>
<Plus className="h-4 w-4" />
Create Category
</button>
)}
/>
{loading ? (
<div className="space-y-3">