diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 9067dd4a8..12872212a 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -12,6 +12,83 @@ description: "React, Inertia, Tailwind v4, and TypeScript frontend standards" - Use the `useForm` helper for forms — follow existing patterns in the codebase. - Use `` or `router.visit()` for navigation — never raw `` tags for internal routes. +## Dialogs (centralized registry) + +App-level dialogs are **not** mounted inline next to their trigger. They live in a central registry and are opened imperatively. This is the required pattern for any new modal/sheet — do not hand-roll local `open` state with a ``. + +**The pieces:** +- `resources/js/components/dialogs/registry.ts` — maps a typed key to a dialog component. +- `resources/js/hooks/use-dialog.ts` — `useDialog()` returns `dialog..open(props)` / `.close()` with full prop typing. +- `resources/js/stores/dialog-store.ts` — Zustand store holding the single active dialog. +- `resources/js/components/dialogs/dialog-host.tsx` — renders the active dialog once, app-wide. + +**Opening a dialog:** +```tsx +const dialog = useDialog(); +// inside a handler / DropdownMenuItem onSelect: +dialog.firewallForm.open({ serverId: server.id, firewallRule }); +``` + +**Simple confirm / destructive actions — don't create a component, use `dialog.confirm`:** +```tsx +dialog.confirm.open({ + title: `Delete rule [${rule.name}]`, + description: 'Are you sure? This cannot be undone.', + variant: 'destructive', + confirmLabel: 'Delete', + method: 'delete', + url: route('firewall.destroy', { server: rule.server_id, firewallRule: rule }), +}); +``` + +**Opening from a dropdown — this is the whole point of the pattern:** use a plain `DropdownMenuItem` with the default `onSelect` so the menu closes, then open the dialog. **Never** wrap a ``/`` inside a `DropdownMenuItem` with `onSelect={(e) => e.preventDefault()}` — that leaves the dropdown stuck open behind the dialog. +```tsx + dialog.editHostedDomain.open({ hostedDomain })}>Edit +``` + +**Authoring a registered dialog component** — it takes control props, renders `` directly (NO `DialogTrigger`, NO local `open` state), and suppresses Radix close-autofocus (the store restores focus): +```tsx +export default function FirewallRuleForm({ + open, + onOpenChange, + serverId, + firewallRule, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + serverId: number; + firewallRule?: FirewallRule; +}) { + const form = useForm({ /* seed from props */ }); + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('firewall.store', { server: serverId }), { onSuccess: () => onOpenChange(false) }); + }; + return ( + + e.preventDefault()}> + {/* ... */} +
{/* fields */}
+ +
+
+ ); +} +``` +Then register it once in `registry.ts` (`firewallForm: FirewallRuleForm`). The consumer immediately gets `dialog.firewallForm.open(props)` with typed props. + +**Rules & gotchas:** +- **Form submit buttons** use `form="" type="submit"` and the `onSubmit` handler calls `e.preventDefault()`. Do not wire submit via the button's `onClick` (a bare `onClick={submit}` with no `preventDefault` lets Enter fire a native form submission alongside the Inertia request). +- **Always call `onOpenChange(false)` in `onSuccess`** to close after a successful request. +- **Lifecycle:** `DialogHost` renders the component with `open` hard-coded `true` and **unmounts it on close** (it never re-renders with `open=false`). Each open is a fresh instance (keyed by `instanceId`), so `useForm` state resets automatically — don't add manual reset-on-open effects. But any `useEffect` that pushes state *outward* based on `open` (e.g. `useInputFocus`'s `setFocused`) **must return a cleanup**, because the component unmounts while `open` is still `true`: + ```tsx + useEffect(() => { + setFocused(open); + return () => setFocused(false); // required — unmount happens with open===true + }, [open, setFocused]); + ``` +- **Authorization:** the registry carries no authz. Every dialog's props must come from server-authorised sources (Inertia page props, API resources) — never URL params or other user-controlled input. + ## Bootstrap Context (`useConfigs`) - Shared catalogue data (provider lists, site types, GitHub App install state, public key text) is **not** in `page.props` — it lives in the Zustand `useBootstrapStore`, exposed via `useConfigs()` and `usePublicKeyText()` from `@/stores/bootstrap-store`. diff --git a/CLAUDE.md b/CLAUDE.md index c190a16ff..2f1fa8b5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ Vito has a specific architecture. Match these patterns exactly: - Inertia pages in `resources/js/pages/`. React components in `resources/js/components/`. Functional components + hooks. - Forms: use the `useForm` helper, follow existing patterns. Navigation: `` or `router.visit()` — never raw `
` for internal routes. +- **Dialogs**: all modals/sheets use the centralized registry (`resources/js/components/dialogs/registry.ts`) opened via `useDialog()` — `dialog..open(props)`, or `dialog.confirm.open({...})` for simple confirms. Never nest ``/`` inside a `DropdownMenuItem`. **Read the "Dialogs" section of `.github/instructions/frontend.instructions.md` before adding or changing any dialog.** - Tailwind v4: `@import "tailwindcss"`, `@theme` for config, **`gap-*` over margins** for sibling spacing. - Use Shadcn components and semantic tokens (`text-foreground`, `bg-background`, `text-muted-foreground`). Avoid hard-coded colors and custom CSS. - **React hooks**: include ALL dependencies in `useEffect` / `useMemo` / `useCallback` arrays. Return cleanup for timers/subscriptions. Stale closures from missing deps are a recurring bug here. diff --git a/app/Enums/DeploymentStatus.php b/app/Enums/DeploymentStatus.php index c145ebdcb..78ff65228 100644 --- a/app/Enums/DeploymentStatus.php +++ b/app/Enums/DeploymentStatus.php @@ -3,8 +3,9 @@ namespace App\Enums; use App\Contracts\VitoEnum; +use Forjed\InertiaTable\Contracts\HasTableDisplay; -enum DeploymentStatus: string implements VitoEnum +enum DeploymentStatus: string implements HasTableDisplay, VitoEnum { case DEPLOYING = 'deploying'; case FINISHED = 'finished'; diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php index 05ddddedf..1a96fc7d1 100644 --- a/app/Http/Controllers/ApplicationController.php +++ b/app/Http/Controllers/ApplicationController.php @@ -12,7 +12,6 @@ use App\Exceptions\SourceControlIsNotConnected; use App\Exceptions\SSHError; use App\Helpers\EnvParser; -use App\Http\Resources\DeploymentResource; use App\Http\Resources\DeploymentScriptResource; use App\Http\Resources\LoadBalancerServerResource; use App\Http\Resources\WorkerResource; @@ -21,6 +20,7 @@ use App\Models\Server; use App\Models\Site; use App\SiteTypes\AbstractProxiedSiteType; +use App\Tables\DeploymentTable; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -52,7 +52,7 @@ public function index(Server $server, Site $site): Response $bootstrapWorker = $type instanceof AbstractProxiedSiteType ? $type->bootstrapWorker() : null; return Inertia::render('application/index', [ - 'deployments' => DeploymentResource::collection($site->deployments()->latest()->simplePaginate(config('web.pagination_size'))), + 'deployments' => DeploymentTable::make($site->deployments())->paginate(), 'deploymentScript' => new DeploymentScriptResource($deploymentScript), 'buildScript' => $buildScript ? new DeploymentScriptResource($buildScript) : null, 'preFlightScript' => $preFlightScript ? new DeploymentScriptResource($preFlightScript) : null, diff --git a/app/Tables/DeploymentTable.php b/app/Tables/DeploymentTable.php new file mode 100644 index 000000000..883fd40bd --- /dev/null +++ b/app/Tables/DeploymentTable.php @@ -0,0 +1,42 @@ + 'deployment']; + + protected string $defaultSort = '-created_at'; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->with('log', 'site'); + } + + protected function columns(): array + { + return [ + TextColumn::make('id', 'ID')->sortable(), + Column::make('commit', 'Commit'), + DateTimeColumn::make('created_at', 'Deployed At')->sortable()->toLocal(), + EnumColumn::make('status', 'Status')->sortable(), + Column::make('release', 'Release'), + Column::data('site_id'), + Column::data('server_id', fn (Deployment $deployment) => $deployment->site->server_id), + Column::data('active'), + Column::data('commit_data'), + Column::data('log', fn (Deployment $deployment) => $deployment->log ? ServerLogResource::make($deployment->log) : null), + ActionsColumn::make(), + ]; + } +} diff --git a/app/Tables/Servers/FirewallRuleTable.php b/app/Tables/Servers/FirewallRuleTable.php index bbe6fb939..c01fb7ab9 100644 --- a/app/Tables/Servers/FirewallRuleTable.php +++ b/app/Tables/Servers/FirewallRuleTable.php @@ -22,9 +22,9 @@ protected function columns(): array { return [ TextColumn::make('name', 'Name')->sortable(), - TextColumn::make('type', 'Type')->uppercase()->sortable(), + TextColumn::make('type', 'Type')->sortable(), TextColumn::make('source', 'Source')->fallback('any')->sortable(), - TextColumn::make('protocol', 'Protocol')->uppercase()->sortable(), + TextColumn::make('protocol', 'Protocol')->sortable(), TextColumn::make('port', 'Port')->sortable(), EnumColumn::make('status', 'Status')->sortable(), Column::data('id'), diff --git a/resources/js/components/dialogs/activate-server-ssl-dialog.tsx b/resources/js/components/dialogs/activate-server-ssl-dialog.tsx new file mode 100644 index 000000000..b39836975 --- /dev/null +++ b/resources/js/components/dialogs/activate-server-ssl-dialog.tsx @@ -0,0 +1,83 @@ +import { FormEvent } from 'react'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { useForm } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircle } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { Textarea } from '@/components/ui/textarea'; + +type ActivateServerSslDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + serverId: number; + sslId: number; +}; + +export default function ActivateServerSslDialog({ open, onOpenChange, serverId, sslId }: ActivateServerSslDialogProps) { + const form = useForm<{ certificate: string; ca: string }>({ + certificate: '', + ca: '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('server-ssls.activate', { server: serverId, ssl: sslId }), { + onSuccess: () => onOpenChange(false), + }); + }; + + return ( + + e.preventDefault()}> + + Activate SSL + Install a signed certificate from your Certificate Authority. + +
+ + + +