diff --git a/AGENTS.md b/AGENTS.md index 3fff0074e..2c403efe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,7 @@ +## Design Reference + +For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo. + === foundation rules === diff --git a/CLAUDE.md b/CLAUDE.md index bb65da405..188889954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,10 @@ ## Project Overview Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4. +## Design Reference + +For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo. + ## Development Environment Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio. diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 9520fdf23..000000000 --- a/DESIGN.md +++ /dev/null @@ -1,752 +0,0 @@ ---- -version: alpha -name: Coolify -description: Self-hosted PaaS. Dark-first utilitarian UI. Purple (light) / yellow (dark) accent swap. Sharp 2px radii. Inset box-shadow inputs with 4px dirty-bar indicator. -colors: - # Brand - coollabs: "#6b16ed" - coollabs-50: "#f5f0ff" - coollabs-100: "#7317ff" - coollabs-200: "#5a12c7" - coollabs-300: "#4a0fa3" - # Dark-mode accent (warning scale) - warning: "#fcd452" - warning-50: "#fefce8" - warning-100: "#fef9c3" - warning-200: "#fef08a" - warning-300: "#fde047" - warning-400: "#fcd452" - warning-500: "#facc15" - warning-600: "#ca8a04" - warning-700: "#a16207" - warning-800: "#854d0e" - warning-900: "#713f12" - # Dark surfaces - base: "#101010" - coolgray-100: "#181818" - coolgray-200: "#202020" - coolgray-300: "#242424" - coolgray-400: "#282828" - coolgray-500: "#323232" - # Light surfaces (Tailwind neutrals) - surface: "#ffffff" - background: "#f9fafb" - border: "#e5e5e5" - text: "#000000" - text-muted: "#737373" - text-placeholder: "#d4d4d4" - # Semantic - success: "#22C55E" - error: "#dc2626" - primary: "{colors.coollabs}" -typography: - h1: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 1.875rem - fontWeight: 700 - lineHeight: 1.2 - h2: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 1.25rem - fontWeight: 700 - h3: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 1.125rem - fontWeight: 700 - h4: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 1rem - fontWeight: 700 - body-md: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 0.875rem - fontWeight: 400 - lineHeight: 1.25rem - label-md: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 0.875rem - fontWeight: 500 - label-sm: - fontFamily: "'Geist Sans', Inter, sans-serif" - fontSize: 0.75rem - fontWeight: 700 - lineHeight: 1rem - mono: - fontFamily: "'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace" - fontSize: 0.875rem - fontWeight: 400 -rounded: - sm: 0.125rem # default — inputs, buttons, cards, modals - md: 0.25rem # coolbox - lg: 0.5rem # callouts - full: 9999px # badges, pills -spacing: - xs: 0.25rem - sm: 0.5rem - md: 1rem - lg: 1.5rem - xl: 2rem - section: 3rem - sidebar-width: 14rem - button-height: 2rem - card-min-height: 4rem - input-py: 0.375rem -components: - button: - backgroundColor: "{colors.surface}" - textColor: "{colors.text}" - rounded: "{rounded.sm}" - height: "{spacing.button-height}" - padding: 0 0.5rem - button-dark: - backgroundColor: "{colors.coolgray-100}" - textColor: "#ffffff" - button-hover: - backgroundColor: "#f5f5f5" - button-hover-dark: - backgroundColor: "{colors.coolgray-200}" - button-highlighted: - backgroundColor: "{colors.coollabs-50}" - textColor: "{colors.coollabs-200}" - button-highlighted-hover: - backgroundColor: "{colors.coollabs}" - textColor: "#ffffff" - button-error: - backgroundColor: "#fef2f2" - textColor: "#991b1b" - button-error-hover: - backgroundColor: "#fca5a5" - textColor: "#ffffff" - input: - backgroundColor: "{colors.surface}" - textColor: "{colors.text}" - rounded: "{rounded.sm}" - padding: 0.375rem 0.5rem - input-dark: - backgroundColor: "{colors.coolgray-100}" - textColor: "#ffffff" - textarea: - typography: "{typography.mono}" - backgroundColor: "{colors.surface}" - rounded: "{rounded.sm}" - box: - backgroundColor: "{colors.surface}" - textColor: "{colors.text}" - rounded: "{rounded.sm}" - padding: "{spacing.sm}" - height: "{spacing.card-min-height}" - box-dark: - backgroundColor: "{colors.coolgray-100}" - textColor: "#ffffff" - box-hover: - backgroundColor: "#f5f5f5" - box-hover-dark: - backgroundColor: "{colors.coollabs-100}" - textColor: "#ffffff" - coolbox: - backgroundColor: "{colors.surface}" - rounded: "{rounded.md}" - padding: "{spacing.sm}" - height: "{spacing.card-min-height}" - badge-success: - backgroundColor: "{colors.success}" - size: 0.75rem - rounded: "{rounded.full}" - badge-warning: - backgroundColor: "{colors.warning}" - size: 0.75rem - rounded: "{rounded.full}" - badge-error: - backgroundColor: "{colors.error}" - size: 0.75rem - rounded: "{rounded.full}" - deprecated-badge: - backgroundColor: "rgba(252, 212, 82, 0.15)" - textColor: "{colors.warning}" - rounded: "{rounded.full}" - padding: 0.125rem 0.5rem - callout-warning: - backgroundColor: "{colors.warning-50}" - textColor: "{colors.warning-800}" - rounded: "{rounded.lg}" - padding: "{spacing.md}" - callout-danger: - backgroundColor: "#fef2f2" - textColor: "#991b1b" - rounded: "{rounded.lg}" - padding: "{spacing.md}" - callout-info: - backgroundColor: "#eff6ff" - textColor: "#1e40af" - rounded: "{rounded.lg}" - padding: "{spacing.md}" - callout-success: - backgroundColor: "#f0fdf4" - textColor: "#166534" - rounded: "{rounded.lg}" - padding: "{spacing.md}" - dropdown: - backgroundColor: "{colors.surface}" - rounded: "{rounded.sm}" - padding: "{spacing.xs}" - dropdown-dark: - backgroundColor: "{colors.coolgray-200}" - dropdown-item-hover: - backgroundColor: "#f5f5f5" - dropdown-item-hover-dark: - backgroundColor: "{colors.coollabs}" - textColor: "#ffffff" - menu-item-active: - backgroundColor: "#e5e5e5" - textColor: "{colors.text}" - rounded: "{rounded.sm}" - menu-item-active-dark: - backgroundColor: "{colors.coolgray-200}" - textColor: "{colors.warning}" - tag: - backgroundColor: "#f5f5f5" - textColor: "{colors.text-muted}" - padding: 0.25rem 0.5rem - kbd: - rounded: "{rounded.sm}" - padding: 0 0.5rem - toast: - backgroundColor: "{colors.surface}" - rounded: "{rounded.sm}" - padding: "{spacing.md}" - width: 20rem - modal-input: - backgroundColor: "{colors.surface}" - rounded: "{rounded.sm}" - modal-input-dark: - backgroundColor: "{colors.base}" - modal-confirmation: - backgroundColor: "#f5f5f5" - rounded: "{rounded.sm}" - modal-confirmation-dark: - backgroundColor: "{colors.base}" ---- - -# Coolify Design System - -## Overview - -Coolify is a self-hosted PaaS (Heroku/Netlify/Vercel alternative) built with Laravel 12, Livewire 3, and Tailwind CSS v4. UI is **dark-first, dense, utilitarian** — operators want information density over whitespace. - -Brand personality: precise, engineered, no-nonsense. No flourish. No gradients outside a single branded upsell. Flat surfaces differentiated by tonal depth, not shadow. - -Two signature traits define the system: - -1. **Purple/Yellow accent swap.** Light mode uses `coollabs` purple `#6b16ed`. Dark mode swaps to `warning` yellow `#fcd452` for focus rings, active nav items, helper icons, loading spinners, highlighted text, helper links. Never use purple as an accent in dark mode. -2. **Inset box-shadow inputs with a 4px "dirty bar".** Inputs and selects have no border — they use `box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px `. When the field is focused or has unsaved changes (`wire:dirty`), the left 4px becomes the accent color — a live visual indicator of modified state. This is the single most distinctive UI detail in Coolify. - -Sharp geometry everywhere: 2px corner radius by default (`rounded-sm`). 8px only on callouts. Shadows used sparingly — one `shadow-sm` on boxes, one drop-shadow on toasts. The rest is flat tonal layers. - -## Colors - -Source of truth: `resources/css/app.css` `@theme` block (Tailwind v4). - -### Palettes - -- **Primary / Coollabs (`#6b16ed`)** — brand purple. Light-mode accent. Used for focus rings, active states, highlighted buttons, spinners, scrollbar thumb. Scale: `coollabs-50 #f5f0ff` (backgrounds), `coollabs #6b16ed` (base), `coollabs-100 #7317ff` (dark-mode button hover), `coollabs-200 #5a12c7` (light-mode text), `coollabs-300 #4a0fa3` (deepest). -- **Warning (`#fcd452`)** — dark-mode accent + callout palette. Full yellow scale `warning-50` through `warning-900`. Swaps in for coollabs under `.dark`. -- **Coolgray (dark surface ladder)** — five shades building dark-mode depth: `base #101010` (page) → `coolgray-100 #181818` (components) → `-200 #202020` (elevated / active nav) → `-300 #242424` (input borders, button borders) → `-400 #282828` (tooltips) → `-500 #323232` (subtle overlays). -- **Semantic** — `success #22C55E` for running/healthy, `error #dc2626` for stopped/danger. -- **Light surfaces** — `gray-50 #f9fafb` (page), `white` (components), `neutral-200 #e5e5e5` (borders), `neutral-500 #737373` (muted text), `neutral-300 #d4d4d4` (placeholders). - -### Dark-mode heading rule (critical) - -Body default text in dark mode is `neutral-400 #a3a3a3`. Headings and card titles MUST explicitly force `text-white` — otherwise they render near-invisible on `coolgray-100 #181818`. This is enforced globally: `h1–h4` all have `dark:text-white` in `app.css`. - -### Default border override - -Tailwind v4 defaults `border-color` to `currentcolor`. Coolify overrides it in `@layer base`: - -```css -*, ::after, ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-coolgray-200, currentcolor); -} -``` - -So any `border` utility without an explicit color gets `coolgray-200 #202020` in dark mode. - -## Typography - -Fonts loaded in `resources/css/fonts.css` (all `woff2`, `font-display: swap`): - -- **Geist Sans** — primary UI font. Variable weight `100 900`. Inter as fallback (static weights 100–900). -- **Geist Mono** — monospace for code, logs, textareas. Variable weight `100 900`. - -Applied via `@theme`: - -```css ---font-sans: 'Geist Sans', Inter, sans-serif; ---font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; ---font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; -``` - -### Heading hierarchy (Tailwind utilities) - -| Element | Utility | -|---|---| -| `h1` | `text-3xl font-bold dark:text-white` | -| `h2` | `text-xl font-bold dark:text-white` | -| `h3` | `text-lg font-bold dark:text-white` | -| `h4` | `text-base font-bold dark:text-white` | - -### Body - -| Context | Utility | -|---|---| -| Body default | `text-sm font-sans antialiased` | -| Label | `text-sm font-medium` | -| Badge / status text | `text-xs font-bold` | -| Box description | `text-xs font-bold text-neutral-500` | -| Caption / kbd | `text-xs` | - -## Layout - -Fixed left sidebar layout on desktop. Mobile collapses to a sticky top bar with hamburger menu overlay. - -### Structure - -- **Sidebar** — fixed, `w-56` (14rem / 224px), `hidden lg:flex`. Inner `flex flex-col overflow-y-auto gap-y-5 scrollbar`. Nav `bg-white dark:bg-base border-r`. -- **Main content** — `lg:pl-56` offset. Inner padding `p-4 sm:px-6 lg:px-8 lg:py-6`. -- **Mobile top bar** — `sticky top-0 z-40 lg:hidden` with `bg-white/95 dark:bg-base/95 backdrop-blur-sm`. - -### Spacing scale - -| Token | Value | Use | -|---|---|---| -| `p-2` | 0.5rem | Component internal padding | -| `p-4` | 1rem | Callout padding | -| `py-1.5` | 0.375rem | Input vertical padding | -| `h-8` | 2rem | Button height | -| `px-2` | 0.5rem | Button horizontal padding | -| `gap-2` | 0.5rem | Button gap | -| `px-2 py-1` | 0.25rem / 0.5rem | Menu item padding | -| `gap-3` | 0.75rem | Menu item gap | -| `mb-12` | 3rem | Section margin | -| `min-h-[4rem]` | 4rem | Card min-height | - -No grid system — flex layouts everywhere. - -## Elevation & Depth - -**Flat + tonal.** Hierarchy comes from background color, not shadows. - -### Dark tonal ladder - -``` -#101010 (base) page background - #181818 (coolgray-100) cards, inputs, components - #202020 (coolgray-200) elevated surfaces, borders, nav active - #242424 (coolgray-300) input borders, button borders - #282828 (coolgray-400) tooltips, hover states - #323232 (coolgray-500) subtle overlays -``` - -### Light tonal ladder - -``` -#f9fafb (gray-50) page background - #ffffff (white) cards, inputs, components - #e5e5e5 (neutral-200) borders - #f5f5f5 (neutral-100) hover backgrounds - #d4d4d4 (neutral-300) deeper hover, nav active -``` - -### Shadows (used sparingly) - -- Boxes: `shadow-sm` (`0 1px 2px 0 rgba(0,0,0,0.05)`) -- Toasts: `shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]` -- Slide-over: `shadow-lg` -- Modal-input: `drop-shadow-sm` - -### Input inset box-shadow system (distinctive) - -Inputs and selects use `box-shadow` instead of `border` — this enables the 4px left dirty-bar indicator: - -```css -/* default */ box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; -/* default dark */ inset 4px 0 0 transparent, inset 0 0 0 2px #242424; -/* focus light */ inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; -/* focus dark */ inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; -/* dirty (same as focus) — set via wire:dirty.class */ -/* disabled / readonly */ box-shadow: none; -``` - -Variant `input-sticky` uses `1px` outer shadow instead of `2px`. - -### Focus ring (buttons, links, checkboxes, non-input) - -`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base` - -## Shapes - -- **Default** — `rounded-sm` (2px). Everything: inputs, buttons, cards, modals, toasts, dropdowns. -- **Coolbox** — `rounded` (4px). Alternate card style with ring-hover. -- **Callouts** — `rounded-lg` (8px). Only exception to the sharp rule. -- **Badges / deprecated badge / pills / avatars** — `rounded-full`. - -Never mix radii within the same view. - -## Components - -All component classes live in `resources/css/utilities.css` as `@utility` blocks, consumed by Blade components under `resources/views/components/`. - -### Forms - -#### Button - -Utility `.button` (`resources/css/utilities.css`): - -``` -flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -Attribute variants (in `app.css`): - -- `button[isHighlighted]` → `text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white` -- `button[isError]` → `text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white` - -Loading: `` — inline `w-4 h-4 dark:text-warning animate-spin` SVG. - -#### Input - -Utility chain `.input-select` → `.input`: - -``` -block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200 dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 focus-visible:outline-none -``` - -Plus the inset box-shadow system (see Elevation). Password variant: `.input[type="password"]` gets `pr-[2.4rem]` for the eye icon. - -**Dirty indicator.** Livewire sets the focus-colored shadow via `wire:dirty.class`: - -```blade -wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" -``` - -Variant `.input-sticky` — same shape, `1px` outer shadow (thinner border). - -#### Select - -Extends `.input-select` + custom SVG dropdown arrow: - -```css -background-image: url("data:image/svg+xml,...stroke='%23000000'..."); -padding-right: 2.5rem; -``` - -Dark mode swaps the SVG stroke to `%23ffffff`. - -#### Checkbox - -Input class: -``` -dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -Container: -``` -form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100 cursor-pointer -``` - -#### Textarea - -Uses the same `input` utility + `font-mono` + dirty-bar via `wire:dirty.class` (identical to input). Optional `@keydown.tab=handleKeydown` inserts 2 spaces on Tab. - -#### Copy-Button - -`resources/views/components/forms/copy-button.blade.php` — readonly `.input` with an absolute-positioned copy icon right-side. Copied state shows a green check (`text-green-500`) for 1 second. Only renders in secure contexts (`window.isSecureContext`). - -### Containers - -#### Box - -Utility `.box`: -``` -relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm -``` - -**Critical child text rule.** On dark hover, background becomes purple `#7317ff` — description text `#737373` disappears. Utilities `.box-title` and `.box-description` include `dark:group-hover:text-white group-hover:text-black` to flip text contrast. - -Variants: `.box-boarding`, `.box-without-bg`, `.box-without-bg-without-border`. - -#### Coolbox - -Utility `.coolbox`: -``` -relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] -``` - -Distinguished by `rounded` (4px, not 2px) and **ring-hover** instead of background change. - -### Status & Badges - -#### Badge base - -``` -inline-block w-3 h-3 text-xs font-bold rounded-full leading-none border border-neutral-200 dark:border-black -``` - -Fill utilities: `.badge-success` (`bg-success`), `.badge-warning` (`bg-warning`), `.badge-error` (`bg-error`). Dashboard variant `.badge-dashboard` is `absolute top-1 right-1 w-2.5 h-2.5`. - -#### Status indicator pattern - -Badge + label side-by-side. Components in `resources/views/components/status/`: - -| Component | Badge | Text color | Loading? | -|---|---|---|---| -| `status/running` | `badge-success` | `text-success` (`#22C55E`) | Swaps to `badge-warning` while checking proxy | -| `status/degraded` | `badge-warning` | `dark:text-warning` (`#fcd452`) | `` + `wire:loading.delay.longer` | -| `status/restarting` | `badge-warning` | `dark:text-warning` | `` | -| `status/stopped` | `badge-error` | `text-error` (`#dc2626`) | `` | - -Layout: `
` → badge → `
{label}
` → optional `({health})` in same color. - -#### Deprecated Badge - -`resources/views/components/deprecated-badge.blade.php`: -``` -px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30 -``` - -#### Tag - -Utility `.tag`: -``` -px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 -``` - -### Overlays - -#### Callout - -Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`. - -| Type | Background | Border | Title text | Body text | -|---|---|---|---|---| -| warning | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | -| danger | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | -| info | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | -| success | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | - -Icon colors (600 light / 400 dark) match type. - -#### Modal (input variant) - -`resources/views/components/modal.blade.php`: -``` -relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col -``` - -Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon. - -#### Modal Confirmation - -`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password): -``` -relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col -``` - -Uses `` for warning. Password step hidden for OAuth users. - -#### Confirm Modal - -`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog. - -#### Popup / Popup-Small - -Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`. - -#### Slide-Over - -`resources/views/components/slide-over.blade.php`: - -Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10` - -Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200` - -#### Toast - -`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system. - -- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`). -- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`. -- Stacks up to 4 (oldest gets scale 82% then burns). -- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack. -- HTML payload sanitized via `window.sanitizeHTML` (XSS guard). -- Per-toast copy-to-clipboard + close buttons. - -Icon colors: - -| Type | Class | -|---|---| -| success | `text-green-500` | -| info | `text-blue-500` | -| warning | `text-orange-400` | -| danger | `text-red-500` | -| default | `text-gray-800` | - -#### Helper / Tooltip - -`resources/views/components/helper.blade.php`. Icon utility `.info-helper`: -``` -cursor-pointer text-coollabs dark:text-warning -``` - -Popup utility `.info-helper-popup`: -``` -hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words -``` - -Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`). - -### Navigation - -#### Sidebar / Navbar - -Component: `resources/views/components/navbar.blade.php`. - -Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base` - -Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`. - -Utility `.menu-item`: -``` -flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 -``` - -Utility `.menu-item-active`: -``` -text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden -``` - -Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons. - -#### Breadcrumbs - -`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: `
- diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 000000000..7d3a858c5 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,7 @@ +middleware(['mcp.enabled', 'auth:sanctum']); diff --git a/routes/api.php b/routes/api.php index c944a6ebc..38ded350a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,8 @@ ], function () { Route::get('/enable', [OtherController::class, 'enable_api']); Route::get('/disable', [OtherController::class, 'disable_api']); + Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']); + Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php new file mode 100644 index 000000000..34ae493cc --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -0,0 +1,194 @@ +where('id', 0)->delete(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function mcpPost(array $payload, ?string $token = null) +{ + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + ]; + if ($token) { + $headers['Authorization'] = 'Bearer '.$token; + } + + return test()->withHeaders($headers)->postJson('/mcp', $payload); +} + +function mcpListTools(string $token) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => (object) [], + ], $token); +} + +function mcpCallTool(string $token, string $name, array $arguments = []) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ], $token); +} + +function mcpToolJson($response): array +{ + return json_decode($response->json('result.content.0.text'), true); +} + +test('MCP endpoint returns 404 when the instance setting is disabled', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]); + Once::flush(); + + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(404); +}); + +test('MCP endpoint rejects unauthenticated requests', function () { + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(401); +}); + +test('MCP endpoint lists tools for an authenticated token', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpListTools($token); + $response->assertOk(); + + $toolNames = collect($response->json('result.tools'))->pluck('name')->all(); + expect($toolNames)->toContain( + 'get_infrastructure_overview', + 'list_servers', + 'get_server', + 'list_projects', + 'list_applications', + 'get_application', + 'list_databases', + 'get_database', + 'list_services', + 'get_service', + ); + expect($toolNames)->not->toContain('get_resource_status'); +}); + +test('list_projects returns summary + pagination scoped to the token team', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + + $otherTeam = Team::factory()->create(); + Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + $body = mcpToolJson($response); + + expect($body)->toHaveKey('data'); + expect($body)->toHaveKey('_pagination'); + expect($body['_pagination']['total'])->toBe(1); + expect($body['_pagination']['per_page'])->toBe(50); + expect($body['_pagination'])->not->toHaveKey('next'); + + $uuids = collect($body['data'])->pluck('uuid')->all(); + $names = collect($body['data'])->pluck('name')->all(); + expect($uuids)->toContain($project->uuid); + expect($names)->not->toContain('Theirs'); + expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']); +}); + +test('list_projects paginates with per_page cap at 100', function () { + for ($i = 0; $i < 3; $i++) { + Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]); + } + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]); + $body = mcpToolJson($response); + + expect($body['_pagination']['total'])->toBe(3); + expect($body['_pagination']['total_pages'])->toBe(2); + expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]); + expect($body['data'])->toHaveCount(2); + + // Verify max cap + $capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]); + $cappedBody = mcpToolJson($capped); + expect($cappedBody['_pagination']['per_page'])->toBe(100); +}); + +test('get_infrastructure_overview returns counts', function () { + Project::create(['name' => 'One', 'team_id' => $this->team->id]); + Project::create(['name' => 'Two', 'team_id' => $this->team->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_infrastructure_overview'); + $response->assertOk(); + + $body = mcpToolJson($response); + expect($body)->toHaveKey('data'); + expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); + expect($body['data']['counts']['projects'])->toBe(2); + expect($body['data']['projects'])->toHaveCount(2); + expect($body['data']['projects'][0])->toHaveKey('counts'); +}); + +test('get_server scrubs sensitive nested data and exposes connection_timeout', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + // creating hook auto-generates a sentinel_token; bump connection_timeout + // via saveQuietly to avoid triggering restartSentinel. + $server->settings->forceFill(['connection_timeout' => 42])->saveQuietly(); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]); + $response->assertOk(); + + $body = mcpToolJson($response); + $raw = json_encode($body); + + expect($raw)->not->toContain('sentinel_token'); + expect($raw)->not->toContain('"team_id"'); + expect($raw)->not->toContain('"private_key_id"'); + expect($body['data']['connection_timeout'])->toBe(42); + expect($body['data']['uuid'])->toBe($server->uuid); +}); + +test('tool calls fail when the token lacks the read ability', function () { + $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text'))->toContain('Missing required permissions'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php new file mode 100644 index 000000000..68d5d335a --- /dev/null +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -0,0 +1,107 @@ +delete(); + $settings = new InstanceSettings([ + 'is_mcp_server_enabled' => false, + 'is_api_enabled' => true, + ]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function makeRootMcpToken(User $user): string +{ + $token = $user->createToken('mcp-root', ['root']); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => '0']); + + return $token->plainTextToken; +} + +function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string +{ + $token = $user->createToken('mcp-write', $abilities); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => (string) $team->id]); + + return $token->plainTextToken; +} + +test('POST /api/v1/mcp/enable enables MCP server with root token', function () { + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->postJson('/api/v1/mcp/enable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server enabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('POST /api/v1/mcp/disable disables MCP server with root token', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->postJson('/api/v1/mcp/disable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server disabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot enable MCP server', function () { + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->postJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot disable MCP server', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->postJson('/api/v1/mcp/disable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { + $response = test()->postJson('/api/v1/mcp/enable'); + $response->assertStatus(401); +}); + +test('read-only token cannot toggle MCP server (lacks write ability)', function () { + $token = makeNonRootMcpToken($this->user, $this->team, ['read']); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->postJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php new file mode 100644 index 000000000..db7eb16b2 --- /dev/null +++ b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php @@ -0,0 +1,70 @@ +teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first(); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]); +}); + +test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id); + + expect($found)->not->toBeNull(); + expect($found->uuid)->toBe($database->uuid); + expect($found)->toBeInstanceOf(StandalonePostgresql::class); +}); + +test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () { + $found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + $count = $modelClass::query()->whereUuid('non-existent-uuid')->count(); + expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query"); + } +}); diff --git a/tests/Feature/ScheduledTaskServerTest.php b/tests/Feature/ScheduledTaskServerTest.php new file mode 100644 index 000000000..68a9020d0 --- /dev/null +++ b/tests/Feature/ScheduledTaskServerTest.php @@ -0,0 +1,66 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +it('returns null when neither application nor service is set', function () { + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + ]); + + expect($task->server())->toBeNull(); +}); + +it('does not throw when accessing dynamic properties on a parentless task', function () { + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + ]); + + expect(fn () => $task->server())->not->toThrow(Exception::class); +}); + +it('resolves server via application destination', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + expect($task->server()?->id)->toBe($this->server->id); +}); + +it('resolves server via service destination', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + expect($task->server()?->id)->toBe($this->server->id); +}); diff --git a/tests/Feature/StandaloneDockerDatabasesTest.php b/tests/Feature/StandaloneDockerDatabasesTest.php new file mode 100644 index 000000000..8d7889149 --- /dev/null +++ b/tests/Feature/StandaloneDockerDatabasesTest.php @@ -0,0 +1,71 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function attachDb(string $modelClass, array $extra, $destination, $environment) +{ + return $modelClass::create(array_merge([ + 'name' => 'test-'.strtolower(class_basename($modelClass)), + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $extra)); +} + +test('StandaloneDocker::databases() includes attached keydb', function () { + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached dragonfly', function () { + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached clickhouse', function () { + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes all 8 standalone database types', function () { + attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(8); + expect($this->destination->attachedTo())->toBeTrue(); +}); diff --git a/tests/Unit/StandaloneDatabaseRegistryTest.php b/tests/Unit/StandaloneDatabaseRegistryTest.php new file mode 100644 index 000000000..7c56d5f8d --- /dev/null +++ b/tests/Unit/StandaloneDatabaseRegistryTest.php @@ -0,0 +1,45 @@ +not->toBeEmpty(); + + $onDisk = collect($files) + ->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php')) + ->reject(fn (string $class) => $class === StandaloneDocker::class) + ->sort() + ->values() + ->all(); + + $registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all(); + + expect($registered)->toBe( + $onDisk, + 'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. ' + .'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.' + ); +}); + +test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () { + expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES); +}); + +test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}"); + expect(is_subclass_of($modelClass, Model::class)) + ->toBeTrue("{$modelClass} is not an Eloquent model"); + expect(method_exists($modelClass, 'team')) + ->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()"); + } +});