From 425eff0a583e0dcab517632b498d00d1437f6b03 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:02:31 +0100 Subject: [PATCH 01/35] docs: add Coolify design system reference Add comprehensive AI/LLM-consumable design system documentation covering: - Design tokens (colors, typography, spacing, shadows, focus rings) - Dark mode strategy with accent color swaps and background hierarchy - Component catalog (buttons, inputs, selects, cards, navigation, modals, etc.) - Interactive state reference (focus, hover, disabled, readonly) - CSS custom properties for theme tokens Includes both Tailwind CSS classes and plain CSS equivalents for all components. --- .ai/design-system.md | 1666 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1666 insertions(+) create mode 100644 .ai/design-system.md diff --git a/.ai/design-system.md b/.ai/design-system.md new file mode 100644 index 000000000..d22adf3c6 --- /dev/null +++ b/.ai/design-system.md @@ -0,0 +1,1666 @@ +# Coolify Design System + +> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. + +--- + +## 1. Design Tokens + +### 1.1 Colors + +#### Brand / Accent + +| Token | Hex | Usage | +|---|---|---| +| `coollabs` | `#6b16ed` | Primary accent (light mode) | +| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | +| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | +| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | +| `coollabs-300` | `#4a0fa3` | Deepest brand shade | +| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | + +#### Warning Scale (used for dark-mode accent + callouts) + +| Token | Hex | +|---|---| +| `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` | + +#### Neutral Grays (dark mode backgrounds) + +| Token | Hex | Usage | +|---|---|---| +| `base` | `#101010` | Page background (dark) | +| `coolgray-100` | `#181818` | Component background (dark) | +| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | +| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | +| `coolgray-400` | `#282828` | Tooltip background (dark) | +| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | + +#### Semantic + +| Token | Hex | Usage | +|---|---|---| +| `success` | `#22C55E` | Running status, success alerts | +| `error` | `#dc2626` | Stopped status, danger actions, error alerts | + +#### Light Mode Defaults + +| Element | Color | +|---|---| +| Page background | `gray-50` (`#f9fafb`) | +| Component background | `white` (`#ffffff`) | +| Borders | `neutral-200` (`#e5e5e5`) | +| Primary text | `black` (`#000000`) | +| Muted text | `neutral-500` (`#737373`) | +| Placeholder text | `neutral-300` (`#d4d4d4`) | + +### 1.2 Typography + +**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) + +#### Heading Hierarchy + +> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. + +| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | +|---|---|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | + +#### Body Text + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | +| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | +| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | +| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | + +### 1.3 Spacing Patterns + +| Context | Value | CSS | +|---|---|---| +| Component internal padding | `p-2` | `padding: 0.5rem;` | +| Callout padding | `p-4` | `padding: 1rem;` | +| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | +| Button height | `h-8` | `height: 2rem;` | +| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | +| Button gap | `gap-2` | `gap: 0.5rem;` | +| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | +| Menu item gap | `gap-3` | `gap: 0.75rem;` | +| Section margin | `mb-12` | `margin-bottom: 3rem;` | +| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | + +### 1.4 Border Radius + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | +| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | +| Badges | `rounded-full` | `border-radius: 9999px;` | +| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | + +### 1.5 Shadows + +#### Input / Select Box-Shadow System + +Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. + +```css +/* Default state */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; + +/* Default state (dark) */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; + +/* Focus state (light) — purple left bar */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; + +/* Focus state (dark) — yellow left bar */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; + +/* Dirty (modified) state — same as focus */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ + +/* Disabled / Readonly */ +box-shadow: none; +``` + +#### Input-Sticky Variant (thinner border) + +```css +/* Uses 1px border instead of 2px */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; +``` + +### 1.6 Focus Ring System + +All interactive elements (buttons, links, checkboxes) share this focus pattern: + +**Tailwind:** +``` +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 +``` + +**Plain CSS:** +```css +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ +} + +/* dark mode */ +.dark :focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. + +--- + +## 2. Dark Mode Strategy + +- **Toggle method**: Class-based — `.dark` class on `` element +- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` +- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` + +### Accent Color Swap + +| Context | Light | Dark | +|---|---|---| +| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | +| Focus ring | `ring-coollabs` | `ring-warning` | +| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | +| Active nav text | `text-black` | `text-warning` | +| Helper/highlight text | `text-coollabs` | `text-warning` | +| Loading spinner | `text-coollabs` | `text-warning` | +| Scrollbar thumb | `coollabs-100` | `coollabs-100` | + +### Background Hierarchy (dark) + +``` +#101010 (base) — page background + └─ #181818 (coolgray-100) — cards, inputs, components + └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active + └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders + └─ #282828 (coolgray-400) — tooltips, hover states + └─ #323232 (coolgray-500) — subtle overlays +``` + +### Background Hierarchy (light) + +``` +#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 +``` + +--- + +## 3. Component Catalog + +### 3.1 Button + +#### Default + +**Tailwind:** +``` +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 +``` + +**Plain CSS:** +```css +.button { + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + padding: 0 0.5rem; + height: 2rem; + font-size: 0.875rem; + font-weight: 500; + text-transform: none; + color: #000; + background: #fff; + border: 2px solid #e5e5e5; + border-radius: 0.125rem; + outline: 0; + cursor: pointer; + min-width: fit-content; +} +.button:hover { background: #f5f5f5; } + +/* Dark */ +.dark .button { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .button:hover { + background: #202020; + color: #fff; +} + +/* Disabled */ +.button:disabled { + cursor: not-allowed; + border-color: transparent; + background: transparent; + color: #d4d4d4; +} +.dark .button:disabled { color: #525252; } +``` + +#### Highlighted (Primary Action) + +**Tailwind** (via `isHighlighted` attribute): +``` +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 +``` + +**Plain CSS:** +```css +.button-highlighted { + color: #5a12c7; + background: #f5f0ff; + border-color: #6b16ed; +} +.button-highlighted:hover { + background: #6b16ed; + color: #fff; +} +.dark .button-highlighted { + color: #fff; + background: rgba(107, 22, 237, 0.2); + border-color: #7317ff; +} +.dark .button-highlighted:hover { + background: #7317ff; + color: #fff; +} +``` + +#### Error / Danger + +**Tailwind** (via `isError` attribute): +``` +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 +``` + +**Plain CSS:** +```css +.button-error { + color: #991b1b; + background: #fef2f2; + border-color: #fca5a5; +} +.button-error:hover { + background: #fca5a5; + color: #fff; +} +.dark .button-error { + color: #fca5a5; + background: rgba(127, 29, 29, 0.3); + border-color: #991b1b; +} +.dark .button-error:hover { + background: #991b1b; + color: #fff; +} +``` + +#### Loading Indicator + +Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). + +--- + +### 3.2 Input + +**Tailwind:** +``` +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 +dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 +placeholder:text-neutral-300 dark:placeholder:text-neutral-700 +read-only:text-neutral-500 read-only:bg-neutral-200 +focus-visible:outline-none +``` + +**Plain CSS:** +```css +.input { + display: block; + padding: 0.375rem 0.5rem; + width: 100%; + font-size: 0.875rem; + color: #000; + background: #fff; + border: 0; + border-radius: 0.125rem; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +} +.input:focus-visible { + outline: none; + box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +} +.input::placeholder { color: #d4d4d4; } +.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } +.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } +.input[type="password"] { padding-right: 2.4rem; } + +/* Dark */ +.dark .input { + background: #181818; + color: #fff; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +} +.dark .input:focus-visible { + box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +} +.dark .input::placeholder { color: #404040; } +.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } +.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } +``` + +#### Dirty (Modified) State + +When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. + +--- + +### 3.3 Select + +Same base styles as Input, plus a custom dropdown arrow SVG: + +**Tailwind:** +``` +w-full block py-1.5 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 +focus-visible:outline-none +``` + +**Additional plain CSS for the dropdown arrow:** +```css +.select { + /* ...same as .input base... */ + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1rem 1rem; + padding-right: 2.5rem; + appearance: none; +} + +/* Dark mode: white stroke arrow */ +.dark .select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); +} +``` + +--- + +### 3.4 Checkbox + +**Tailwind:** +``` +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:** +``` +flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit +dark:hover:bg-coolgray-100 cursor-pointer +``` + +**Plain CSS:** +```css +.checkbox { + border-color: #404040; + color: #282828; + background: #181818; + border-radius: 0.125rem; + cursor: pointer; +} +.checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} + +.checkbox-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 0.25rem 0.5rem 0.25rem 0; + min-width: fit-content; + cursor: pointer; +} +.dark .checkbox-container:hover { background: #181818; } +``` + +--- + +### 3.5 Textarea + +Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). + +**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: + +**Tailwind:** +``` +block w-full text-sm text-black rounded-sm border border-neutral-200 +dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 +font-mono 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 +``` + +**Plain CSS:** +```css +.textarea { + display: block; + width: 100%; + font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #000; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; +} +.textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; +} +.dark .textarea { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .textarea:focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. + +--- + +### 3.6 Box / Card + +#### Standard Box + +**Tailwind:** +``` +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 +``` + +**Plain CSS:** +```css +.box { + position: relative; + display: flex; + flex-direction: column; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + color: #000; + cursor: pointer; + transition: background-color 150ms, color 150ms; + text-decoration: none; +} +.box:hover { background: #f5f5f5; color: #000; } + +.dark .box { + background: #181818; + border-color: #242424; + color: #fff; +} +.dark .box:hover { + background: #7317ff; + color: #fff; +} + +/* IMPORTANT: child text must also turn white/black on hover, + since description text (#737373) is invisible on purple bg */ +.box:hover .box-title { color: #000; } +.box:hover .box-description { color: #000; } +.dark .box:hover .box-title { color: #fff; } +.dark .box:hover .box-description { color: #fff; } + +/* Desktop: row layout */ +@media (min-width: 1024px) { + .box { flex-direction: row; } +} +``` + +#### Coolbox (Ring Hover) + +**Tailwind:** +``` +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] +``` + +**Plain CSS:** +```css +.coolbox { + position: relative; + display: flex; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.25rem; + cursor: pointer; + transition: all 150ms; +} +.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } + +.dark .coolbox { + background: #181818; + border-color: #282828; +} +.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } +``` + +#### Box Text + +> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. + +```css +.box-title { + font-weight: 700; + color: #000; /* light mode: black */ +} +.dark .box-title { + color: #fff; /* dark mode: MUST be white, not grey */ +} + +.box-description { + font-size: 0.75rem; + font-weight: 700; + color: #737373; +} +/* On hover: description must become visible against colored bg */ +.box:hover .box-description { color: #000; } +.dark .box:hover .box-description { color: #fff; } +``` + +--- + +### 3.7 Badge / Status Indicator + +**Tailwind:** +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none +border border-neutral-200 dark:border-black +``` + +**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) + +**Plain CSS:** +```css +.badge { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + border: 1px solid #e5e5e5; +} +.dark .badge { border-color: #000; } + +.badge-success { background: #22C55E; } +.badge-warning { background: #fcd452; } +.badge-error { background: #dc2626; } +``` + +#### Status Text Pattern + +Status indicators combine a badge dot with text: + +```html +
+
+
+ Running +
+
+``` + +| Status | Badge Class | Text Color | +|---|---|---| +| Running | `badge-success` | `text-success` (`#22C55E`) | +| Stopped | `badge-error` | `text-error` (`#dc2626`) | +| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | +| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | + +--- + +### 3.8 Dropdown + +**Container Tailwind:** +``` +p-1 mt-1 bg-white border rounded-sm shadow-sm +dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 +``` + +**Item Tailwind:** +``` +flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs +transition-colors cursor-pointer select-none dark:text-white +hover:bg-neutral-100 dark:hover:bg-coollabs +outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs +``` + +**Plain CSS:** +```css +.dropdown { + padding: 0.25rem; + margin-top: 0.25rem; + background: #fff; + border: 1px solid #d4d4d4; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.dark .dropdown { + background: #202020; + border-color: #242424; +} + +.dropdown-item { + display: flex; + position: relative; + gap: 0.5rem; + justify-content: flex-start; + align-items: center; + padding: 0.25rem 1rem 0.25rem 0.5rem; + width: 100%; + font-size: 0.75rem; + cursor: pointer; + user-select: none; + transition: background-color 150ms; +} +.dropdown-item:hover { background: #f5f5f5; } +.dark .dropdown-item { color: #fff; } +.dark .dropdown-item:hover { background: #6b16ed; } +``` + +--- + +### 3.9 Sidebar / Navigation + +#### Sidebar Container + Page Layout + +The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. + +**Tailwind (sidebar wrapper — desktop):** +``` +hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 +``` + +**Tailwind (sidebar inner — scrollable):** +``` +flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 +``` + +**Tailwind (nav element):** +``` +flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base +``` + +**Tailwind (main content area):** +``` +lg:pl-56 +``` + +**Tailwind (main content padding):** +``` +p-4 sm:px-6 lg:px-8 lg:py-6 +``` + +**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** +``` +sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden +bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 +``` + +**Tailwind (mobile hamburger icon):** +``` +-m-2.5 p-2.5 dark:text-warning +``` + +**Plain CSS:** +```css +/* Sidebar — desktop only */ +.sidebar { + display: none; +} +@media (min-width: 1024px) { + .sidebar { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 50; + width: 14rem; /* 224px */ + min-width: 0; + } +} + +.sidebar-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + gap: 1.25rem; + min-width: 0; +} + +/* Nav element */ +.sidebar-nav { + display: flex; + flex-direction: column; + flex: 1; + padding: 0 0.5rem; + background: #fff; + border-right: 1px solid #d4d4d4; +} +.dark .sidebar-nav { + background: #101010; + border-right-color: #202020; +} + +/* Main content offset */ +@media (min-width: 1024px) { + .main-content { padding-left: 14rem; } +} + +.main-content-inner { + padding: 1rem; +} +@media (min-width: 640px) { + .main-content-inner { padding: 1rem 1.5rem; } +} +@media (min-width: 1024px) { + .main-content-inner { padding: 1.5rem 2rem; } +} + +/* Mobile top bar — visible below lg breakpoint */ +.mobile-topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + gap: 1.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(212, 212, 212, 0.5); +} +.dark .mobile-topbar { + background: rgba(16, 16, 16, 0.95); + border-bottom-color: rgba(32, 32, 32, 0.5); +} +@media (min-width: 1024px) { + .mobile-topbar { display: none; } +} + +/* Mobile sidebar overlay (shown when hamburger is tapped) */ +.sidebar-mobile { + position: relative; + display: flex; + flex: 1; + width: 100%; + max-width: 14rem; + min-width: 0; +} +.sidebar-mobile-scroll { + display: flex; + flex-direction: column; + padding-bottom: 0.5rem; + overflow-y: auto; + min-width: 14rem; + gap: 1.25rem; + min-width: 0; +} +.dark .sidebar-mobile-scroll { background: #181818; } +``` + +#### Sidebar Header (Logo + Search) + +**Tailwind:** +``` +flex lg:pt-6 pt-4 pb-4 pl-2 +``` + +**Logo:** +``` +text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity +``` + +**Search button:** +``` +flex items-center gap-1.5 px-2.5 py-1.5 +bg-neutral-100 dark:bg-coolgray-100 +border border-neutral-300 dark:border-coolgray-200 +rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors +``` + +**Search kbd hint:** +``` +px-1 py-0.5 text-xs font-semibold +text-neutral-500 dark:text-neutral-400 +bg-neutral-200 dark:bg-coolgray-200 rounded +``` + +**Plain CSS:** +```css +.sidebar-header { + display: flex; + padding: 1rem 0 1rem 0.5rem; +} +@media (min-width: 1024px) { + .sidebar-header { padding-top: 1.5rem; } +} + +.sidebar-logo { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.025em; + color: #000; + text-decoration: none; +} +.dark .sidebar-logo { color: #fff; } +.sidebar-logo:hover { opacity: 0.8; } + +.sidebar-search-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + background: #f5f5f5; + border: 1px solid #d4d4d4; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 150ms; +} +.sidebar-search-btn:hover { background: #e5e5e5; } +.dark .sidebar-search-btn { + background: #181818; + border-color: #202020; +} +.dark .sidebar-search-btn:hover { background: #202020; } + +.sidebar-search-kbd { + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: #737373; + background: #e5e5e5; + border-radius: 0.25rem; +} +.dark .sidebar-search-kbd { + color: #a3a3a3; + background: #202020; +} +``` + +#### Menu Item List + +**Tailwind (list container):** +``` +flex flex-col flex-1 gap-y-7 +``` + +**Tailwind (inner list):** +``` +flex flex-col h-full space-y-1.5 +``` + +**Plain CSS:** +```css +.menu-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 1.75rem; + list-style: none; + padding: 0; + margin: 0; +} + +.menu-list-inner { + display: flex; + flex-direction: column; + height: 100%; + gap: 0.375rem; + list-style: none; + padding: 0; + margin: 0; +} +``` + +#### Menu Item + +**Tailwind:** +``` +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 +``` + +#### Menu Item Active + +**Tailwind:** +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +#### Menu Item Icon / Label + +``` +/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white +/* Label */ min-w-0 flex-1 truncate +``` + +**Plain CSS:** +```css +.menu-item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-item:hover { background: #d4d4d4; } +.dark .menu-item:hover { background: #181818; color: #fff; } + +.menu-item-active { + color: #000; + background: #e5e5e5; + border-radius: 0.125rem; +} +.dark .menu-item-active { + background: #202020; + color: #fcd452; +} + +.menu-item-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; +} + +.menu-item-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +#### Sub-Menu Item + +```css +.sub-menu-item { + /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; +} +.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } +``` + +--- + +### 3.10 Callout / Alert + +Four types: `warning`, `danger`, `info`, `success`. + +**Structure:** +```html +
+
+
+
Title
+
Content
+
+
+``` + +**Base Tailwind:** +``` +relative p-4 border rounded-lg +``` + +**Type Colors:** + +| 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` | + +**Plain CSS (warning example):** +```css +.callout { + position: relative; + padding: 1rem; + border: 1px solid; + border-radius: 0.5rem; +} + +.callout-warning { + background: #fefce8; + border-color: #fde047; +} +.dark .callout-warning { + background: rgba(113, 63, 18, 0.3); + border-color: #854d0e; +} + +.callout-title { + font-size: 1rem; + font-weight: 700; +} +.callout-warning .callout-title { color: #854d0e; } +.dark .callout-warning .callout-title { color: #fde047; } + +.callout-text { + margin-top: 0.5rem; + font-size: 0.875rem; +} +.callout-warning .callout-text { color: #a16207; } +.dark .callout-warning .callout-text { color: #fef08a; } +``` + +**Icon colors per type:** +- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) +- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) +- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) +- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) + +--- + +### 3.11 Toast / Notification + +**Container Tailwind:** +``` +relative flex flex-col items-start +shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] +w-full transition-all duration-100 ease-out +dark:bg-coolgray-100 bg-white +dark:border dark:border-coolgray-200 +rounded-sm sm:max-w-xs +``` + +**Plain CSS:** +```css +.toast { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 20rem; + background: #fff; + border-radius: 0.125rem; + box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); + transition: all 100ms ease-out; +} +.dark .toast { + background: #181818; + border: 1px solid #202020; +} +``` + +**Icon colors per toast type:** + +| Type | Color | Hex | +|---|---|---| +| Success | `text-green-500` | `#22c55e` | +| Info | `text-blue-500` | `#3b82f6` | +| Warning | `text-orange-400` | `#fb923c` | +| Danger | `text-red-500` | `#ef4444` | + +**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. + +--- + +### 3.12 Modal + +**Tailwind (dialog-based):** +``` +rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col +``` + +**Modal Input variant container:** +``` +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 +``` + +**Modal Confirmation container:** +``` +relative w-full border rounded-sm +min-w-full lg:min-w-[36rem] max-w-[48rem] +max-h-[calc(100vh-2rem)] +bg-neutral-100 border-neutral-400 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Plain CSS:** +```css +.modal-box { + border-radius: 0.125rem; + max-height: calc(100vh - 5rem); + display: flex; + flex-direction: column; +} + +.modal-input { + position: relative; + width: 100%; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); + background: #fff; + display: flex; + flex-direction: column; +} +.dark .modal-input { + background: #101010; + border-color: #242424; +} + +/* Desktop sizing */ +@media (min-width: 1024px) { + .modal-input { + width: auto; + min-width: 42rem; + max-width: 56rem; + } +} +``` + +**Modal header:** +```css +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + flex-shrink: 0; +} +.modal-header h3 { + font-size: 1.5rem; + font-weight: 700; +} +``` + +**Close button:** +```css +.modal-close { + width: 2rem; + height: 2rem; + border-radius: 9999px; + color: #fff; +} +.modal-close:hover { background: #242424; } +``` + +--- + +### 3.13 Slide-Over Panel + +**Tailwind:** +``` +fixed inset-y-0 right-0 flex max-w-full pl-10 +``` + +**Inner panel:** +``` +max-w-xl w-screen +flex flex-col h-full py-6 +border-l shadow-lg +bg-neutral-50 dark:bg-base +dark:border-neutral-800 border-neutral-200 +``` + +**Plain CSS:** +```css +.slide-over { + position: fixed; + top: 0; + bottom: 0; + right: 0; + display: flex; + max-width: 100%; + padding-left: 2.5rem; +} + +.slide-over-panel { + max-width: 36rem; + width: 100vw; + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem 0; + border-left: 1px solid #e5e5e5; + box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); + background: #fafafa; +} +.dark .slide-over-panel { + background: #101010; + border-color: #262626; +} +``` + +--- + +### 3.14 Tag + +**Tailwind:** +``` +px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 +dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +**Plain CSS:** +```css +.tag { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + color: #737373; + background: #f5f5f5; + cursor: pointer; +} +.tag:hover { background: #e5e5e5; } +.dark .tag { background: #181818; } +.dark .tag:hover { background: #242424; } +``` + +--- + +### 3.15 Loading Spinner + +**Tailwind:** +``` +w-4 h-4 text-coollabs dark:text-warning animate-spin +``` + +**Plain CSS + SVG:** +```css +.loading-spinner { + width: 1rem; + height: 1rem; + color: #6b16ed; + animation: spin 1s linear infinite; +} +.dark .loading-spinner { color: #fcd452; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +**SVG structure:** +```html + + + + +``` + +--- + +### 3.16 Helper / Tooltip + +**Tailwind (trigger icon):** +``` +cursor-pointer text-coollabs dark:text-warning +``` + +**Tailwind (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 +``` + +**Plain CSS:** +```css +.helper-icon { + cursor: pointer; + color: #6b16ed; +} +.dark .helper-icon { color: #fcd452; } + +.helper-popup { + display: none; + position: absolute; + z-index: 40; + font-size: 0.75rem; + border-radius: 0.125rem; + color: #404040; + background: #e5e5e5; + max-width: 24rem; + white-space: normal; + word-break: break-word; + padding: 1rem; +} +.dark .helper-popup { + background: #282828; + color: #d4d4d4; + border: 1px solid #323232; +} + +/* Show on parent hover */ +.helper:hover .helper-popup { display: block; } +``` + +--- + +### 3.17 Highlighted Text + +**Tailwind:** +``` +inline-block font-bold text-coollabs dark:text-warning +``` + +**Plain CSS:** +```css +.text-highlight { + display: inline-block; + font-weight: 700; + color: #6b16ed; +} +.dark .text-highlight { color: #fcd452; } +``` + +--- + +### 3.18 Scrollbar + +**Tailwind:** +``` +scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 +dark:scrollbar-track-coolgray-200 scrollbar-thin +``` + +**Plain CSS:** +```css +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #e5e5e5; } +::-webkit-scrollbar-thumb { background: #7317ff; } +.dark ::-webkit-scrollbar-track { background: #202020; } +``` + +--- + +### 3.19 Table + +**Plain CSS:** +```css +table { min-width: 100%; border-collapse: separate; } +table, tbody { border-bottom: 1px solid #d4d4d4; } +.dark table, .dark tbody { border-color: #202020; } + +thead { text-transform: uppercase; } + +tr { color: #000; } +tr:hover { background: #e5e5e5; } +.dark tr { color: #a3a3a3; } +.dark tr:hover { background: #000; } + +th { + padding: 0.875rem 0.75rem; + text-align: left; + color: #000; +} +.dark th { color: #fff; } +th:first-child { padding-left: 1.5rem; } + +td { padding: 1rem 0.75rem; white-space: nowrap; } +td:first-child { padding-left: 1.5rem; font-weight: 700; } +``` + +--- + +### 3.20 Keyboard Shortcut Indicator + +**Tailwind:** +``` +px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning +``` + +**Plain CSS:** +```css +.kbd { + padding: 0 0.5rem; + font-size: 0.75rem; + border-radius: 0.125rem; + border: 1px dashed #404040; +} +.dark .kbd { color: #fcd452; } +``` + +--- + +## 4. Base Element Styles + +These global styles are applied to all HTML elements: + +```css +/* Page */ +html, body { + width: 100%; + min-height: 100%; + background: #f9fafb; + font-family: Inter, sans-serif; +} +.dark html, .dark body { + background: #101010; + color: #a3a3a3; +} + +body { + min-height: 100vh; + font-size: 0.875rem; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Links */ +a:hover { color: #000; } +.dark a:hover { color: #fff; } + +/* Labels */ +.dark label { color: #a3a3a3; } + +/* Sections */ +section { margin-bottom: 3rem; } + +/* Default border color override */ +*, ::after, ::before, ::backdrop { + border-color: #202020; /* coolgray-200 */ +} + +/* Select options */ +.dark option { + color: #fff; + background: #181818; +} +``` + +--- + +## 5. Interactive State Reference + +### Focus + +| Element Type | Mechanism | Light | Dark | +|---|---|---|---| +| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | +| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | +| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | + +### Hover + +| Element | Light | Dark | +|---|---|---| +| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | +| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | +| Button (error) | `bg-red-300` | `bg-red-800` | +| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | +| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | +| Menu item | `bg-neutral-300` | `bg-coolgray-100` | +| Dropdown item | `bg-neutral-100` | `bg-coollabs` | +| Table row | `bg-neutral-200` | `bg-black` | +| Link | `text-black` | `text-white` | +| Checkbox container | — | `bg-coolgray-100` | + +### Disabled + +```css +/* Universal disabled pattern */ +:disabled { + cursor: not-allowed; + color: #d4d4d4; /* neutral-300 */ + background: transparent; + border-color: transparent; +} +.dark :disabled { + color: #525252; /* neutral-600 */ +} + +/* Input-specific */ +.input:disabled { + background: #e5e5e5; /* neutral-200 */ + color: #737373; /* neutral-500 */ + box-shadow: none; +} +.dark .input:disabled { + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +### Readonly + +```css +.input:read-only { + color: #737373; + background: #e5e5e5; + box-shadow: none; +} +.dark .input:read-only { + color: #737373; + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +--- + +## 6. CSS Custom Properties (Theme Tokens) + +For use in any CSS framework or plain CSS: + +```css +:root { + /* Font */ + --font-sans: Inter, sans-serif; + + /* Brand */ + --color-base: #101010; + --color-coollabs: #6b16ed; + --color-coollabs-50: #f5f0ff; + --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; + + /* Neutral grays (dark backgrounds) */ + --color-coolgray-100: #181818; + --color-coolgray-200: #202020; + --color-coolgray-300: #242424; + --color-coolgray-400: #282828; + --color-coolgray-500: #323232; + + /* Warning / dark accent */ + --color-warning: #fcd452; + --color-warning-50: #fefce8; + --color-warning-100: #fef9c3; + --color-warning-200: #fef08a; + --color-warning-300: #fde047; + --color-warning-400: #fcd452; + --color-warning-500: #facc15; + --color-warning-600: #ca8a04; + --color-warning-700: #a16207; + --color-warning-800: #854d0e; + --color-warning-900: #713f12; + + /* Semantic */ + --color-success: #22C55E; + --color-error: #dc2626; +} +``` From 635b1097d3853fc2a11fec5ba7a9d66add8f18c1 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:44:13 +0100 Subject: [PATCH 02/35] feat(service): add sure --- public/svgs/sure.png | Bin 0 -> 4990 bytes templates/compose/sure.yaml | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 public/svgs/sure.png create mode 100644 templates/compose/sure.yaml diff --git a/public/svgs/sure.png b/public/svgs/sure.png new file mode 100644 index 0000000000000000000000000000000000000000..7a0bb69c14cdca1ee45c08a60f4fb90796072380 GIT binary patch literal 4990 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 Date: Fri, 6 Feb 2026 09:49:28 +0530 Subject: [PATCH 03/35] feat(service): disable maybe This service is no longer maintained by the original authors and their github repository is archived, more info on https://github.com/we-promise/sure?tab=readme-ov-file#backstory --- templates/compose/maybe.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/maybe.yaml b/templates/compose/maybe.yaml index 27bcbc5a1..1cc738d7e 100644 --- a/templates/compose/maybe.yaml +++ b/templates/compose/maybe.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/maybe-finance/maybe # slogan: Maybe, the OS for your personal finances. # category: productivity From 95e93ad899d6270008155c0f5af0735d6f0e965b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:48:16 +0100 Subject: [PATCH 04/35] chore: prepare for PR --- .../Api/ApplicationsController.php | 7 +- .../Controllers/Api/ServicesController.php | 11 +- openapi.json | 27 +-- openapi.yaml | 14 +- .../EnvironmentVariableUpdateApiTest.php | 194 ++++++++++++++++++ 5 files changed, 210 insertions(+), 43 deletions(-) create mode 100644 tests/Feature/EnvironmentVariableUpdateApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1e045ff5a..57bcc13f6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -19,8 +19,8 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -2919,10 +2919,7 @@ class ApplicationsController extends Controller new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 27fdb1ba8..ee4d84f10 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1141,10 +1141,7 @@ class ServicesController extends Controller new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] @@ -1265,10 +1262,8 @@ class ServicesController extends Controller new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variables updated.'], - ] + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') ) ), ] diff --git a/openapi.json b/openapi.json index bd502865a..cbd79ca1d 100644 --- a/openapi.json +++ b/openapi.json @@ -3063,13 +3063,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9617,13 +9611,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9721,13 +9709,10 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variables updated." - } - }, - "type": "object" + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } } } } diff --git a/openapi.yaml b/openapi.yaml index 11148f43b..172607117 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1952,9 +1952,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6027,9 +6025,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6075,9 +6071,9 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variables updated.' } - type: object + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php new file mode 100644 index 000000000..9c45dc5ae --- /dev/null +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -0,0 +1,194 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/services/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Environment variable not found.']); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/services/non-existent-uuid/envs', [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); From c5afd8638e9b6d73a5eb1ce0e3b448d1ac824ef7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:24:24 +0100 Subject: [PATCH 05/35] chore: prepare for PR --- .../Project/New/GithubPrivateRepository.php | 5 +++++ .../views/components/forms/datalist.blade.php | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 40d2674e2..6acb17f82 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,6 +75,11 @@ class GithubPrivateRepository extends Component $this->github_apps = GithubApp::private(); } + public function updatedSelectedRepositoryId(): void + { + $this->loadBranches(); + } + public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 1d9a3b263..a0ad099dc 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -99,8 +99,14 @@ {{-- Unified Input Container with Tags Inside --}}
From 13af86734179da86497ecab13f189afee461ef70 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:22:57 +0530 Subject: [PATCH 06/35] fix(service): glitchtip webdashboard doesn't load --- templates/compose/glitchtip.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/compose/glitchtip.yaml b/templates/compose/glitchtip.yaml index 3e86d813a..5f239daee 100644 --- a/templates/compose/glitchtip.yaml +++ b/templates/compose/glitchtip.yaml @@ -1,9 +1,9 @@ # documentation: https://glitchtip.com -# slogan: GlitchTip is a self-hosted, open-source error tracking system. +# slogan: GlitchTip is a error tracking system. # category: monitoring -# tags: error, tracking, open-source, self-hosted, sentry +# tags: error, tracking, sentry # logo: svgs/glitchtip.png -# port: 8080 +# port: 8000 services: postgres: @@ -29,14 +29,14 @@ services: retries: 10 web: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: - - SERVICE_URL_GLITCHTIP_8080 + - SERVICE_URL_GLITCHTIP_8000 - DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip} - SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION - EMAIL_URL=${EMAIL_URL:-consolemail://} @@ -53,7 +53,7 @@ services: retries: 10 worker: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 command: ./bin/run-celery-with-beat.sh depends_on: postgres: @@ -77,7 +77,7 @@ services: retries: 10 migrate: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 restart: "no" depends_on: postgres: From 47a3f2e2cd8bdf0d23e52c277dbd90db50a0c0a8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:25:47 +0100 Subject: [PATCH 07/35] test: add Pest browser testing with SQLite :memory: schema Set up end-to-end browser testing using Pest Browser Plugin + Playwright. New v4 test suite uses SQLite :memory: database with pre-generated schema dump (database/schema/testing-schema.sql) instead of running migrations, enabling faster test startup. - Add pestphp/pest-plugin-browser dependency - Create GenerateTestingSchema command to export PostgreSQL schema to SQLite - Add .env.testing configuration for isolated test environment - Implement v4 test directory structure (Feature, Browser, Unit tests) - Update Pest skill documentation with browser testing patterns, API reference, debugging techniques, and common pitfalls - Configure phpunit.xml and tests/Pest.php for v4 suite - Update package.json and docker-compose.dev.yml for testing dependencies --- .claude/skills/pest-testing/SKILL.md | 157 +- .env.testing | 15 + .../Commands/GenerateTestingSchema.php | 222 +++ composer.json | 1 + composer.lock | 1565 ++++++++++++++- config/database.php | 14 +- ...11_11_125366_add_index_to_activity_log.php | 8 + ...5_12_08_135600_add_performance_indexes.php | 8 + database/schema/testing-schema.sql | 1754 +++++++++++++++++ docker-compose.dev.yml | 2 + package-lock.json | 47 +- package.json | 5 +- phpunit.xml | 15 +- resources/views/auth/register.blade.php | 8 +- templates/service-templates-latest.json | 34 +- templates/service-templates.json | 34 +- tests/Pest.php | 2 +- tests/v4/Browser/LoginTest.php | 46 + tests/v4/Browser/RegistrationTest.php | 62 + tests/v4/Feature/SqliteDatabaseTest.php | 18 + 20 files changed, 3887 insertions(+), 130 deletions(-) create mode 100644 .env.testing create mode 100644 app/Console/Commands/GenerateTestingSchema.php create mode 100644 database/schema/testing-schema.sql create mode 100644 tests/v4/Browser/LoginTest.php create mode 100644 tests/v4/Browser/RegistrationTest.php create mode 100644 tests/v4/Feature/SqliteDatabaseTest.php diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md index 67455e7e6..9ca79830a 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -23,19 +23,28 @@ Activate this skill when: Use `search-docs` for detailed Pest 4 patterns and documentation. -## Basic Usage +## Test Directory Structure -### Creating Tests +- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete) +- `tests/v4/Feature/` — New feature tests (SQLite :memory: database) +- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright) +- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete) -All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations. -### Test Organization +Do NOT remove tests without approval. -- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. -- Browser tests: `tests/Browser/` directory. -- Do NOT remove tests without approval - these are core application code. +## Running Tests -### Basic Test Structure +- All v4 tests: `php artisan test --compact tests/v4/` +- Browser tests: `php artisan test --compact tests/v4/Browser/` +- Feature tests: `php artisan test --compact tests/v4/Feature/` +- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php` +- Filter: `php artisan test --compact --filter=testName` +- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed` +- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug` + +## Basic Test Structure @@ -45,24 +54,10 @@ it('is true', function () { -### Running Tests - -- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. -- Run all tests: `php artisan test --compact`. -- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. - ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - -it('returns all', function () { - $this->postJson('/api/docs', [])->assertSuccessful(); -}); - - - | Use | Instead of | |-----|------------| | `assertSuccessful()` | `assertStatus(200)` | @@ -75,7 +70,7 @@ Import mock function before use: `use function Pest\Laravel\mock;` ## Datasets -Use datasets for repetitive tests (validation rules, etc.): +Use datasets for repetitive tests: @@ -88,73 +83,94 @@ it('has emails', function (string $email) { -## Pest 4 Features +## Browser Testing (Pest Browser Plugin + Playwright) -| Feature | Purpose | -|---------|---------| -| Browser Testing | Full integration tests in real browsers | -| Smoke Testing | Validate multiple pages quickly | -| Visual Regression | Compare screenshots for visual changes | -| Test Sharding | Parallel CI runs | -| Architecture Testing | Enforce code conventions | +Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically. -### Browser Test Example +### Key Rules -Browser tests run in real browsers for full integration testing: +1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory: +2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it +3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user +4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API +5. **Place tests in `tests/v4/Browser/`** +6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this -- Browser tests live in `tests/Browser/`. -- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. -- Use `RefreshDatabase` for clean state per test. -- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. -- Test on multiple browsers (Chrome, Firefox, Safari) if requested. -- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging. +### Browser Test Template - + +actingAs(User::factory()->create()); +uses(RefreshDatabase::class); - $page = visit('/sign-in'); - - $page->assertSee('Sign In') - ->assertNoJavaScriptErrors() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!'); - - Notification::assertSent(ResetPassword::class); +beforeEach(function () { + InstanceSettings::create(['id' => 0]); }); +it('can visit the page', function () { + $page = visit('/login'); + + $page->assertSee('Login'); +}); -### Smoke Testing +### Browser Test with Form Interaction -Quickly validate multiple pages have no JavaScript errors: + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); + $page = visit('/login'); + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records'); +}); -### Visual Regression Testing +### Browser API Reference -Capture and compare screenshots to detect visual changes. +| Method | Purpose | +|--------|---------| +| `visit('/path')` | Navigate to a page | +| `->fill('field', 'value')` | Fill an input by name | +| `->click('Button Text')` | Click a button/link by text | +| `->assertSee('text')` | Assert visible text | +| `->assertDontSee('text')` | Assert text is not visible | +| `->assertPathIs('/path')` | Assert current URL path | +| `->assertSeeIn('.selector', 'text')` | Assert text in element | +| `->screenshot()` | Capture screenshot | +| `->debug()` | Pause test, keep browser open | +| `->wait(seconds)` | Wait N seconds | -### Test Sharding +### Debugging -Split tests across parallel processes for faster CI runs. +- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure +- `->debug()` pauses and keeps browser open (press Enter to continue) +- `->screenshot()` captures state at any point +- `--headed` flag shows browser, `--debug` pauses on failure -### Architecture Testing +## SQLite Testing Setup -Pest 4 includes architecture testing (from Pest 3): +v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`. + +### Regenerating the Schema + +When migrations change, regenerate from the running PostgreSQL database: + +```bash +docker exec coolify php artisan schema:generate-testing +``` + +## Architecture Testing @@ -171,4 +187,7 @@ arch('controllers') - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it** +- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty** +- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard** diff --git a/.env.testing b/.env.testing new file mode 100644 index 000000000..2f79f3389 --- /dev/null +++ b/.env.testing @@ -0,0 +1,15 @@ +APP_ENV=testing +APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= +APP_DEBUG=true + +DB_CONNECTION=testing + +CACHE_DRIVER=array +SESSION_DRIVER=array +QUEUE_CONNECTION=sync +MAIL_MAILER=array +TELESCOPE_ENABLED=false + +REDIS_HOST=127.0.0.1 + +SELF_HOSTED=true diff --git a/app/Console/Commands/GenerateTestingSchema.php b/app/Console/Commands/GenerateTestingSchema.php new file mode 100644 index 000000000..00fd90c25 --- /dev/null +++ b/app/Console/Commands/GenerateTestingSchema.php @@ -0,0 +1,222 @@ + 'INTEGER', + '/\binteger\b/' => 'INTEGER', + '/\bsmallint\b/' => 'INTEGER', + '/\bboolean\b/' => 'INTEGER', + '/character varying\(\d+\)/' => 'TEXT', + '/timestamp\(\d+\) without time zone/' => 'TEXT', + '/timestamp\(\d+\) with time zone/' => 'TEXT', + '/\bjsonb\b/' => 'TEXT', + '/\bjson\b/' => 'TEXT', + '/\buuid\b/' => 'TEXT', + '/double precision/' => 'REAL', + '/numeric\(\d+,\d+\)/' => 'REAL', + '/\bdate\b/' => 'TEXT', + ]; + + private array $castRemovals = [ + '::character varying', + '::text', + '::integer', + '::boolean', + '::timestamp without time zone', + '::timestamp with time zone', + '::numeric', + ]; + + public function handle(): int + { + $connection = $this->option('connection'); + + if (DB::connection($connection)->getDriverName() !== 'pgsql') { + $this->error("Connection '{$connection}' is not PostgreSQL."); + + return self::FAILURE; + } + + $this->info('Reading schema from PostgreSQL...'); + + $tables = $this->getTables($connection); + $lastMigration = DB::connection($connection) + ->table('migrations') + ->orderByDesc('id') + ->value('migration'); + + $output = []; + $output[] = '-- Generated by: php artisan schema:generate-testing'; + $output[] = '-- Date: '.now()->format('Y-m-d H:i:s'); + $output[] = '-- Last migration: '.($lastMigration ?? 'none'); + $output[] = ''; + + foreach ($tables as $table) { + $columns = $this->getColumns($connection, $table); + $output[] = $this->generateCreateTable($table, $columns); + } + + $indexes = $this->getIndexes($connection, $tables); + foreach ($indexes as $index) { + $output[] = $index; + } + + $output[] = ''; + $output[] = '-- Migration records'; + + $migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get(); + foreach ($migrations as $m) { + $migration = str_replace("'", "''", $m->migration); + $output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});"; + } + + $path = database_path('schema/testing-schema.sql'); + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + + file_put_contents($path, implode("\n", $output)."\n"); + + $this->info("Schema written to {$path}"); + $this->info(count($tables).' tables, '.count($migrations).' migration records.'); + + return self::SUCCESS; + } + + private function getTables(string $connection): array + { + return collect(DB::connection($connection)->select( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" + ))->pluck('tablename')->toArray(); + } + + private function getColumns(string $connection, string $table): array + { + return DB::connection($connection)->select( + "SELECT column_name, data_type, character_maximum_length, column_default, + is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? + ORDER BY ordinal_position", + [$table] + ); + } + + private function generateCreateTable(string $table, array $columns): string + { + $lines = []; + + foreach ($columns as $col) { + $lines[] = ' '.$this->generateColumnDef($table, $col); + } + + return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n"; + } + + private function generateColumnDef(string $table, object $col): string + { + $name = $col->column_name; + $sqliteType = $this->convertType($col); + + // Auto-increment primary key for id columns + if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) { + return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"; + } + + $parts = ["\"{$name}\"", $sqliteType]; + + // Default value + $default = $col->column_default; + if ($default !== null && ! str_contains($default, 'nextval')) { + $default = $this->cleanDefault($default); + $parts[] = "DEFAULT {$default}"; + } + + // NOT NULL + if ($col->is_nullable === 'NO') { + $parts[] = 'NOT NULL'; + } + + return implode(' ', $parts); + } + + private function convertType(object $col): string + { + $pgType = $col->data_type; + + return match (true) { + in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER', + $pgType === 'boolean' => 'INTEGER', + in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT', + str_contains($pgType, 'timestamp') => 'TEXT', + in_array($pgType, ['json', 'jsonb']) => 'TEXT', + $pgType === 'uuid' => 'TEXT', + $pgType === 'double precision' => 'REAL', + $pgType === 'numeric' => 'REAL', + $pgType === 'date' => 'TEXT', + default => 'TEXT', + }; + } + + private function cleanDefault(string $default): string + { + foreach ($this->castRemovals as $cast) { + $default = str_replace($cast, '', $default); + } + + // Remove array type casts like ::text[] + $default = preg_replace('/::[\w\s]+(\[\])?/', '', $default); + + return $default; + } + + private function getIndexes(string $connection, array $tables): array + { + $results = []; + + $indexes = DB::connection($connection)->select( + "SELECT indexname, tablename, indexdef FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname" + ); + + foreach ($indexes as $idx) { + $def = $idx->indexdef; + + // Skip primary key indexes + if (str_contains($def, '_pkey')) { + continue; + } + + // Skip PG-specific indexes (GIN, GIST, expression indexes) + if (preg_match('/USING (gin|gist)/i', $def)) { + continue; + } + if (str_contains($def, '->>') || str_contains($def, '::')) { + continue; + } + + // Convert to SQLite-compatible CREATE INDEX + $unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : ''; + + // Extract columns from the index definition + if (preg_match('/\((.+)\)$/', $def, $m)) { + $cols = $m[1]; + $results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});"; + } + } + + return $results; + } +} diff --git a/composer.json b/composer.json index 1c36df1ea..fc71dea8f 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.8.3", "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin-browser": "^4.2", "phpstan/phpstan": "^2.1.38", "rector/rector": "^2.3.5", "serversideup/spin": "^3.1.1", diff --git a/composer.lock b/composer.lock index 708382500..7c1e000e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d22beb5f7db243339d029a288bdfe33e", + "content-hash": "21d43f41d2f2e275403e77ccc66ec553", "packages": [ { "name": "aws/aws-crt-php", @@ -11636,6 +11636,1228 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-02-08T18:16:29+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v3.16.5", @@ -11814,6 +13036,50 @@ ], "time": "2026-01-08T07:23:06+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "driftingly/rector-laravel", "version": "2.1.9", @@ -12096,6 +13362,64 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", "version": "v2.1.1", @@ -12505,6 +13829,90 @@ }, "time": "2025-12-30T17:31:31+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "shasum": "" + }, + "require": { + "league/uri": "^7.8", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -13003,6 +14411,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.3", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.3" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.3", + "nunomaduro/collision": "^8.8.3", + "orchestra/testbench": "^10.8.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-01-11T20:32:34+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", @@ -13957,6 +15448,78 @@ ], "time": "2026-01-28T15:22:48+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", diff --git a/config/database.php b/config/database.php index 366ff90b5..79da0eaf7 100644 --- a/config/database.php +++ b/config/database.php @@ -54,18 +54,10 @@ return [ ], 'testing' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_TEST_URL'), - 'host' => env('DB_TEST_HOST', 'postgres'), - 'port' => env('DB_TEST_PORT', '5432'), - 'database' => env('DB_TEST_DATABASE', 'coolify_test'), - 'username' => env('DB_TEST_USERNAME', 'coolify'), - 'password' => env('DB_TEST_PASSWORD', 'password'), - 'charset' => 'utf8', + 'driver' => 'sqlite', + 'database' => ':memory:', 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', + 'foreign_key_constraints' => true, ], ], diff --git a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php index 0c281ff40..5ebf62fe3 100644 --- a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php +++ b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php @@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration { public function up() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb'); DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)'); @@ -18,6 +22,10 @@ class AddIndexToActivityLog extends Migration public function down() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid'); DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json'); diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php index 680c4b4f7..ce38d7cc2 100644 --- a/database/migrations/2025_12_08_135600_add_performance_indexes.php +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -22,6 +22,10 @@ return new class extends Migration public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [$table, $columns, $indexName]) { if (! $this->indexExists($indexName)) { $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); @@ -32,6 +36,10 @@ return new class extends Migration public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [, , $indexName]) { DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); } diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql new file mode 100644 index 000000000..edbc35db4 --- /dev/null +++ b/database/schema/testing-schema.sql @@ -0,0 +1,1754 @@ +-- Generated by: php artisan schema:generate-testing +-- Date: 2026-02-11 13:10:01 +-- Last migration: 2025_12_17_000002_add_restart_tracking_to_standalone_databases + +CREATE TABLE IF NOT EXISTS "activity_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "log_name" TEXT, + "description" TEXT NOT NULL, + "subject_type" TEXT, + "subject_id" INTEGER, + "causer_type" TEXT, + "causer_id" INTEGER, + "properties" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "event" TEXT, + "batch_uuid" TEXT +); + +CREATE TABLE IF NOT EXISTS "additional_destinations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" INTEGER NOT NULL, + "server_id" INTEGER NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "standalone_docker_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_deployment_queues" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" TEXT NOT NULL, + "deployment_uuid" TEXT NOT NULL, + "pull_request_id" INTEGER DEFAULT 0 NOT NULL, + "force_rebuild" INTEGER DEFAULT false NOT NULL, + "commit" TEXT DEFAULT 'HEAD' NOT NULL, + "status" TEXT DEFAULT 'queued' NOT NULL, + "is_webhook" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "logs" TEXT, + "current_process_id" TEXT, + "restart_only" INTEGER DEFAULT false NOT NULL, + "git_type" TEXT, + "server_id" INTEGER, + "application_name" TEXT, + "server_name" TEXT, + "deployment_url" TEXT, + "destination_id" TEXT, + "only_this_server" INTEGER DEFAULT false NOT NULL, + "rollback" INTEGER DEFAULT false NOT NULL, + "commit_message" TEXT, + "is_api" INTEGER DEFAULT false NOT NULL, + "build_server_id" INTEGER, + "horizon_job_id" TEXT, + "horizon_job_worker" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_previews" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "pull_request_id" INTEGER NOT NULL, + "pull_request_html_url" TEXT NOT NULL, + "pull_request_issue_comment_id" TEXT, + "fqdn" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "git_type" TEXT, + "docker_compose_domains" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_static" INTEGER DEFAULT false NOT NULL, + "is_git_submodules_enabled" INTEGER DEFAULT true NOT NULL, + "is_git_lfs_enabled" INTEGER DEFAULT true NOT NULL, + "is_auto_deploy_enabled" INTEGER DEFAULT true NOT NULL, + "is_force_https_enabled" INTEGER DEFAULT true NOT NULL, + "is_debug_enabled" INTEGER DEFAULT false NOT NULL, + "is_preview_deployments_enabled" INTEGER DEFAULT false NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_gpu_enabled" INTEGER DEFAULT false NOT NULL, + "gpu_driver" TEXT DEFAULT 'nvidia' NOT NULL, + "gpu_count" TEXT, + "gpu_device_ids" TEXT, + "gpu_options" TEXT, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "is_swarm_only_worker_nodes" INTEGER DEFAULT true NOT NULL, + "is_raw_compose_deployment_enabled" INTEGER DEFAULT false NOT NULL, + "is_build_server_enabled" INTEGER DEFAULT false NOT NULL, + "is_consistent_container_name_enabled" INTEGER DEFAULT false NOT NULL, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "custom_internal_name" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "is_env_sorting_enabled" INTEGER DEFAULT false NOT NULL, + "is_container_label_readonly_enabled" INTEGER DEFAULT true NOT NULL, + "is_preserve_repository_enabled" INTEGER DEFAULT false NOT NULL, + "disable_build_cache" INTEGER DEFAULT false NOT NULL, + "is_spa" INTEGER DEFAULT false NOT NULL, + "is_git_shallow_clone_enabled" INTEGER DEFAULT true NOT NULL, + "is_pr_deployments_public_enabled" INTEGER DEFAULT false NOT NULL, + "use_build_secrets" INTEGER DEFAULT false NOT NULL, + "inject_build_args_to_dockerfile" INTEGER DEFAULT true NOT NULL, + "include_source_commit_in_build" INTEGER DEFAULT false NOT NULL, + "docker_images_to_keep" INTEGER DEFAULT 2 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "repository_project_id" INTEGER, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "fqdn" TEXT, + "config_hash" TEXT, + "git_repository" TEXT NOT NULL, + "git_branch" TEXT NOT NULL, + "git_commit_sha" TEXT DEFAULT 'HEAD' NOT NULL, + "git_full_url" TEXT, + "docker_registry_image_name" TEXT, + "docker_registry_image_tag" TEXT, + "build_pack" TEXT NOT NULL, + "static_image" TEXT DEFAULT 'nginx:alpine' NOT NULL, + "install_command" TEXT, + "build_command" TEXT, + "start_command" TEXT, + "ports_exposes" TEXT NOT NULL, + "ports_mappings" TEXT, + "base_directory" TEXT DEFAULT '/' NOT NULL, + "publish_directory" TEXT, + "health_check_path" TEXT DEFAULT '/' NOT NULL, + "health_check_port" TEXT, + "health_check_host" TEXT DEFAULT 'localhost' NOT NULL, + "health_check_method" TEXT DEFAULT 'GET' NOT NULL, + "health_check_return_code" INTEGER DEFAULT 200 NOT NULL, + "health_check_scheme" TEXT DEFAULT 'http' NOT NULL, + "health_check_response_text" TEXT, + "health_check_interval" INTEGER DEFAULT 5 NOT NULL, + "health_check_timeout" INTEGER DEFAULT 5 NOT NULL, + "health_check_retries" INTEGER DEFAULT 10 NOT NULL, + "health_check_start_period" INTEGER DEFAULT 5 NOT NULL, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "preview_url_template" TEXT DEFAULT '{{pr_id}}.{{domain}}' NOT NULL, + "destination_type" TEXT, + "destination_id" INTEGER, + "source_type" TEXT, + "source_id" INTEGER, + "private_key_id" INTEGER, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "dockerfile" TEXT, + "health_check_enabled" INTEGER DEFAULT false NOT NULL, + "dockerfile_location" TEXT, + "custom_labels" TEXT, + "dockerfile_target_build" TEXT, + "manual_webhook_secret_github" TEXT, + "manual_webhook_secret_gitlab" TEXT, + "docker_compose_location" TEXT DEFAULT '/docker-compose.yaml', + "docker_compose" TEXT, + "docker_compose_raw" TEXT, + "docker_compose_domains" TEXT, + "deleted_at" TEXT, + "docker_compose_custom_start_command" TEXT, + "docker_compose_custom_build_command" TEXT, + "swarm_replicas" INTEGER DEFAULT 1 NOT NULL, + "swarm_placement_constraints" TEXT, + "manual_webhook_secret_bitbucket" TEXT, + "custom_docker_run_options" TEXT, + "post_deployment_command" TEXT, + "post_deployment_command_container" TEXT, + "pre_deployment_command" TEXT, + "pre_deployment_command_container" TEXT, + "watch_paths" TEXT, + "custom_healthcheck_found" INTEGER DEFAULT false NOT NULL, + "manual_webhook_secret_gitea" TEXT, + "redirect" TEXT DEFAULT 'both' NOT NULL, + "compose_parsing_version" TEXT DEFAULT '1' NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "custom_nginx_configuration" TEXT, + "custom_network_aliases" TEXT, + "is_http_basic_auth_enabled" INTEGER DEFAULT false NOT NULL, + "http_basic_auth_username" TEXT, + "http_basic_auth_password" TEXT, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_init_scripts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "script" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_provider_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "discord_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "discord_enabled" INTEGER DEFAULT false NOT NULL, + "discord_webhook_url" TEXT, + "deployment_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_discord_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_discord_notifications" INTEGER DEFAULT true NOT NULL, + "discord_ping_enabled" INTEGER DEFAULT true NOT NULL, + "server_patch_discord_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_discord_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "docker_cleanup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "cleanup_log" TEXT, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "email_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "use_instance_email_settings" INTEGER DEFAULT false NOT NULL, + "deployment_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_email_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_email_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_email_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "is_preview" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL, + "uuid" TEXT NOT NULL, + "order" INTEGER, + "is_required" INTEGER DEFAULT false NOT NULL, + "is_shared" INTEGER DEFAULT false NOT NULL, + "resourceable_type" TEXT, + "resourceable_id" INTEGER, + "is_runtime" INTEGER DEFAULT true NOT NULL, + "is_buildtime" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environments" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "failed_jobs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "connection" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "exception" TEXT NOT NULL, + "failed_at" TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "github_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "app_id" INTEGER, + "installation_id" INTEGER, + "client_id" TEXT, + "client_secret" TEXT, + "webhook_secret" TEXT, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "contents" TEXT, + "metadata" TEXT, + "pull_requests" TEXT, + "administration" TEXT +); + +CREATE TABLE IF NOT EXISTS "gitlab_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "app_id" INTEGER, + "app_secret" TEXT, + "oauth_id" INTEGER, + "group_name" TEXT, + "public_key" TEXT, + "webhook_token" TEXT, + "deploy_key_id" INTEGER, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "instance_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "public_ipv4" TEXT, + "public_ipv6" TEXT, + "fqdn" TEXT, + "public_port_min" INTEGER DEFAULT 9000 NOT NULL, + "public_port_max" INTEGER DEFAULT 9100 NOT NULL, + "do_not_track" INTEGER DEFAULT false NOT NULL, + "is_auto_update_enabled" INTEGER DEFAULT true NOT NULL, + "is_registration_enabled" INTEGER DEFAULT true NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "next_channel" INTEGER DEFAULT false NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "is_dns_validation_enabled" INTEGER DEFAULT true NOT NULL, + "custom_dns_servers" TEXT DEFAULT '1.1.1.1', + "instance_name" TEXT, + "is_api_enabled" INTEGER DEFAULT false NOT NULL, + "allowed_ips" TEXT, + "auto_update_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "update_check_frequency" TEXT DEFAULT '0 * * * *' NOT NULL, + "new_version_available" INTEGER DEFAULT false NOT NULL, + "instance_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "helper_version" TEXT DEFAULT '1.0.0' NOT NULL, + "disable_two_step_confirmation" INTEGER DEFAULT false NOT NULL, + "is_sponsorship_popup_enabled" INTEGER DEFAULT true NOT NULL, + "dev_helper_version" TEXT, + "is_wire_navigate_enabled" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_file_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "fs_path" TEXT NOT NULL, + "mount_path" TEXT, + "content" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_directory" INTEGER DEFAULT false NOT NULL, + "chown" TEXT, + "chmod" TEXT, + "is_based_on_git" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_persistent_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "mount_path" TEXT NOT NULL, + "host_path" TEXT, + "container_id" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "migrations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "migration" TEXT NOT NULL, + "batch" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "oauth_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "provider" TEXT NOT NULL, + "enabled" INTEGER DEFAULT false NOT NULL, + "client_id" TEXT, + "client_secret" TEXT, + "redirect_uri" TEXT, + "tenant" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "base_url" TEXT +); + +CREATE TABLE IF NOT EXISTS "password_reset_tokens" ( + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "personal_access_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "tokenable_type" TEXT NOT NULL, + "tokenable_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "token" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "abilities" TEXT, + "last_used_at" TEXT, + "expires_at" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "private_keys" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "private_key" TEXT NOT NULL, + "is_git_related" INTEGER DEFAULT false NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "fingerprint" TEXT +); + +CREATE TABLE IF NOT EXISTS "project_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "projects" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "pushover_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "pushover_enabled" INTEGER DEFAULT false NOT NULL, + "pushover_user_key" TEXT, + "pushover_api_token" TEXT, + "deployment_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_pushover_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "s3_storages" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "region" TEXT DEFAULT 'us-east-1' NOT NULL, + "key" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "endpoint" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_usable" INTEGER DEFAULT false NOT NULL, + "unusable_email_sent" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "size" TEXT, + "filename" TEXT, + "scheduled_database_backup_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "database_name" TEXT, + "finished_at" TEXT, + "local_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_uploaded" INTEGER +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backups" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "description" TEXT, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "save_s3" INTEGER DEFAULT true NOT NULL, + "frequency" TEXT NOT NULL, + "database_backup_retention_amount_locally" INTEGER DEFAULT 0 NOT NULL, + "database_type" TEXT NOT NULL, + "database_id" INTEGER NOT NULL, + "s3_storage_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "databases_to_backup" TEXT, + "dump_all" INTEGER DEFAULT false NOT NULL, + "database_backup_retention_days_locally" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_locally" REAL DEFAULT '0' NOT NULL, + "database_backup_retention_amount_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_days_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_s3" REAL DEFAULT '0' NOT NULL, + "timeout" INTEGER DEFAULT 3600 NOT NULL, + "disable_local_backup" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_task_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "scheduled_task_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT, + "started_at" TEXT, + "retry_count" INTEGER DEFAULT 0 NOT NULL, + "duration" REAL, + "error_details" TEXT +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "name" TEXT NOT NULL, + "command" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "container" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "application_id" INTEGER, + "service_id" INTEGER, + "team_id" INTEGER NOT NULL, + "timeout" INTEGER DEFAULT 300 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "server_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_swarm_manager" INTEGER DEFAULT false NOT NULL, + "is_jump_server" INTEGER DEFAULT false NOT NULL, + "is_build_server" INTEGER DEFAULT false NOT NULL, + "is_reachable" INTEGER DEFAULT false NOT NULL, + "is_usable" INTEGER DEFAULT false NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "wildcard_domain" TEXT, + "is_cloudflare_tunnel" INTEGER DEFAULT false NOT NULL, + "is_logdrain_newrelic_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_newrelic_license_key" TEXT, + "logdrain_newrelic_base_uri" TEXT, + "is_logdrain_highlight_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_highlight_project_id" TEXT, + "is_logdrain_axiom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_axiom_dataset_name" TEXT, + "logdrain_axiom_api_key" TEXT, + "is_swarm_worker" INTEGER DEFAULT false NOT NULL, + "is_logdrain_custom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_custom_config" TEXT, + "logdrain_custom_config_parser" TEXT, + "concurrent_builds" INTEGER DEFAULT 2 NOT NULL, + "dynamic_timeout" INTEGER DEFAULT 3600 NOT NULL, + "force_disabled" INTEGER DEFAULT false NOT NULL, + "is_metrics_enabled" INTEGER DEFAULT false NOT NULL, + "generate_exact_labels" INTEGER DEFAULT false NOT NULL, + "force_docker_cleanup" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "docker_cleanup_threshold" INTEGER DEFAULT 80 NOT NULL, + "server_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "delete_unused_volumes" INTEGER DEFAULT false NOT NULL, + "delete_unused_networks" INTEGER DEFAULT false NOT NULL, + "is_sentinel_enabled" INTEGER DEFAULT true NOT NULL, + "sentinel_token" TEXT, + "sentinel_metrics_refresh_rate_seconds" INTEGER DEFAULT 10 NOT NULL, + "sentinel_metrics_history_days" INTEGER DEFAULT 7 NOT NULL, + "sentinel_push_interval_seconds" INTEGER DEFAULT 60 NOT NULL, + "sentinel_custom_url" TEXT, + "server_disk_usage_notification_threshold" INTEGER DEFAULT 80 NOT NULL, + "is_sentinel_debug_enabled" INTEGER DEFAULT false NOT NULL, + "server_disk_usage_check_frequency" TEXT DEFAULT '0 23 * * *' NOT NULL, + "is_terminal_enabled" INTEGER DEFAULT true NOT NULL, + "deployment_queue_limit" INTEGER DEFAULT 25 NOT NULL, + "disable_application_image_retention" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "servers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ip" TEXT NOT NULL, + "port" INTEGER DEFAULT 22 NOT NULL, + "user" TEXT DEFAULT 'root' NOT NULL, + "team_id" INTEGER NOT NULL, + "private_key_id" INTEGER NOT NULL, + "proxy" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "unreachable_notification_sent" INTEGER DEFAULT false NOT NULL, + "unreachable_count" INTEGER DEFAULT 0 NOT NULL, + "high_disk_usage_notification_sent" INTEGER DEFAULT false NOT NULL, + "log_drain_notification_sent" INTEGER DEFAULT false NOT NULL, + "swarm_cluster" INTEGER, + "validation_logs" TEXT, + "sentinel_updated_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT, + "ip_previous" TEXT, + "hetzner_server_id" INTEGER, + "cloud_provider_token_id" INTEGER, + "hetzner_server_status" TEXT, + "is_validating" INTEGER DEFAULT false NOT NULL, + "detected_traefik_version" TEXT, + "traefik_outdated_info" TEXT +); + +CREATE TABLE IF NOT EXISTS "service_applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "fqdn" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "required_fqdn" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "service_databases" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "public_port" INTEGER, + "is_public" INTEGER DEFAULT false NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL, + "custom_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "services" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "server_id" INTEGER, + "description" TEXT, + "docker_compose_raw" TEXT NOT NULL, + "docker_compose" TEXT, + "destination_type" TEXT, + "destination_id" INTEGER, + "deleted_at" TEXT, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "config_hash" TEXT, + "service_type" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "compose_parsing_version" TEXT DEFAULT '2' NOT NULL +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" TEXT NOT NULL, + "user_id" INTEGER, + "ip_address" TEXT, + "user_agent" TEXT, + "payload" TEXT NOT NULL, + "last_activity" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "shared_environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "type" TEXT DEFAULT 'team' NOT NULL, + "team_id" INTEGER NOT NULL, + "project_id" INTEGER, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "slack_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "slack_enabled" INTEGER DEFAULT false NOT NULL, + "slack_webhook_url" TEXT, + "deployment_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_slack_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_slack_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_slack_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ssl_certificates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "ssl_certificate" TEXT NOT NULL, + "ssl_private_key" TEXT NOT NULL, + "configuration_dir" TEXT, + "mount_path" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "server_id" INTEGER NOT NULL, + "common_name" TEXT NOT NULL, + "subject_alternative_names" TEXT, + "valid_until" TEXT NOT NULL, + "is_ca_certificate" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_clickhouses" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "clickhouse_admin_user" TEXT DEFAULT 'default' NOT NULL, + "clickhouse_admin_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'clickhouse/clickhouse-server:25.11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "clickhouse_db" TEXT DEFAULT 'default' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "network" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dragonflies" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "dragonfly_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'docker.dragonflydb.io/dragonflydb/dragonfly' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_keydbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "keydb_password" TEXT NOT NULL, + "keydb_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'eqalpha/keydb:latest' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mariadbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mariadb_root_password" TEXT NOT NULL, + "mariadb_user" TEXT DEFAULT 'mariadb' NOT NULL, + "mariadb_password" TEXT NOT NULL, + "mariadb_database" TEXT DEFAULT 'default' NOT NULL, + "mariadb_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mariadb:11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mongodbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mongo_conf" TEXT, + "mongo_initdb_root_username" TEXT DEFAULT 'root' NOT NULL, + "mongo_initdb_root_password" TEXT NOT NULL, + "mongo_initdb_database" TEXT DEFAULT 'default' NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mongo:7' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mysqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mysql_root_password" TEXT NOT NULL, + "mysql_user" TEXT DEFAULT 'mysql' NOT NULL, + "mysql_password" TEXT NOT NULL, + "mysql_database" TEXT DEFAULT 'default' NOT NULL, + "mysql_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mysql:8' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'REQUIRED' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_postgresqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "postgres_user" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_password" TEXT NOT NULL, + "postgres_db" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_initdb_args" TEXT, + "postgres_host_auth_method" TEXT, + "init_scripts" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'postgres:16-alpine' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "postgres_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_redis" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "redis_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'redis:7.2' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "stripe_invoice_paid" INTEGER DEFAULT false NOT NULL, + "stripe_subscription_id" TEXT, + "stripe_customer_id" TEXT, + "stripe_cancel_at_period_end" INTEGER DEFAULT false NOT NULL, + "stripe_plan_id" TEXT, + "stripe_feedback" TEXT, + "stripe_comment" TEXT, + "stripe_trial_already_ended" INTEGER DEFAULT false NOT NULL, + "stripe_past_due" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "swarm_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "network" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "taggables" ( + "tag_id" INTEGER NOT NULL, + "taggable_id" INTEGER NOT NULL, + "taggable_type" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "tags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "team_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_invitations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "team_id" INTEGER NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "link" TEXT NOT NULL, + "via" TEXT DEFAULT 'link' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_user" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "teams" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "personal_team" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "show_boarding" INTEGER DEFAULT false NOT NULL, + "custom_server_limit" INTEGER +); + +CREATE TABLE IF NOT EXISTS "telegram_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "telegram_enabled" INTEGER DEFAULT false NOT NULL, + "telegram_token" TEXT, + "telegram_chat_id" TEXT, + "deployment_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_deployment_success_thread_id" TEXT, + "telegram_notifications_deployment_failure_thread_id" TEXT, + "telegram_notifications_status_change_thread_id" TEXT, + "telegram_notifications_backup_success_thread_id" TEXT, + "telegram_notifications_backup_failure_thread_id" TEXT, + "telegram_notifications_scheduled_task_success_thread_id" TEXT, + "telegram_notifications_scheduled_task_failure_thread_id" TEXT, + "telegram_notifications_docker_cleanup_success_thread_id" TEXT, + "telegram_notifications_docker_cleanup_failure_thread_id" TEXT, + "telegram_notifications_server_disk_usage_thread_id" TEXT, + "telegram_notifications_server_reachable_thread_id" TEXT, + "telegram_notifications_server_unreachable_thread_id" TEXT, + "server_patch_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_server_patch_thread_id" TEXT, + "telegram_notifications_traefik_outdated_thread_id" TEXT, + "traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_entries" ( + "sequence" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "family_hash" TEXT, + "should_display_on_index" INTEGER DEFAULT true NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "telescope_entries_tags" ( + "entry_uuid" TEXT NOT NULL, + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_monitoring" ( + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "user_changelog_reads" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "user_id" INTEGER NOT NULL, + "release_tag" TEXT NOT NULL, + "read_at" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT DEFAULT 'Anonymous' NOT NULL, + "email" TEXT NOT NULL, + "email_verified_at" TEXT, + "password" TEXT, + "remember_token" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "two_factor_secret" TEXT, + "two_factor_recovery_codes" TEXT, + "two_factor_confirmed_at" TEXT, + "force_password_reset" INTEGER DEFAULT false NOT NULL, + "marketing_emails" INTEGER DEFAULT true NOT NULL, + "pending_email" TEXT, + "email_change_code" TEXT, + "email_change_code_expires_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "webhook_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "webhook_enabled" INTEGER DEFAULT false NOT NULL, + "webhook_url" TEXT, + "deployment_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "traefik_outdated_webhook_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE INDEX IF NOT EXISTS "activity_log_log_name_index" ON "activity_log" (log_name); +CREATE INDEX IF NOT EXISTS "causer" ON "activity_log" (causer_type, causer_id); +CREATE INDEX IF NOT EXISTS "subject" ON "activity_log" (subject_type, subject_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_deployment_queues_deployment_uuid_unique" ON "application_deployment_queues" (deployment_uuid); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_app_status_pr_created" ON "application_deployment_queues" (application_id, status, pull_request_id, created_at); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_status_server" ON "application_deployment_queues" (status, server_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_fqdn_unique" ON "application_previews" (fqdn); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_uuid_unique" ON "application_previews" (uuid); +CREATE INDEX IF NOT EXISTS "applications_destination_type_destination_id_index" ON "applications" (destination_type, destination_id); +CREATE INDEX IF NOT EXISTS "applications_source_type_source_id_index" ON "applications" (source_type, source_id); +CREATE UNIQUE INDEX IF NOT EXISTS "applications_uuid_unique" ON "applications" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_init_scripts_team_id" ON "cloud_init_scripts" (team_id); +CREATE INDEX IF NOT EXISTS "cloud_provider_tokens_team_id_provider_index" ON "cloud_provider_tokens" (team_id, provider); +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_provider_tokens_uuid_unique" ON "cloud_provider_tokens" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_provider_tokens_team_id" ON "cloud_provider_tokens" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "discord_notification_settings_team_id_unique" ON "discord_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "docker_cleanup_executions_uuid_unique" ON "docker_cleanup_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "email_notification_settings_team_id_unique" ON "email_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "environment_variables_resourceable_type_resourceable_id_index" ON "environment_variables" (resourceable_type, resourceable_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_name_project_id_unique" ON "environments" (name, project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_uuid_unique" ON "environments" (uuid); +CREATE INDEX IF NOT EXISTS "idx_environments_project_id" ON "environments" (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "failed_jobs_uuid_unique" ON "failed_jobs" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "github_apps_uuid_unique" ON "github_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "gitlab_apps_uuid_unique" ON "gitlab_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "local_file_volumes_mount_path_resource_id_resource_type_unique" ON "local_file_volumes" (mount_path, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_file_volumes_resource_type_resource_id_index" ON "local_file_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "local_persistent_volumes_name_resource_id_resource_type_unique" ON "local_persistent_volumes" (name, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_persistent_volumes_resource_type_resource_id_index" ON "local_persistent_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "oauth_settings_provider_unique" ON "oauth_settings" (provider); +CREATE UNIQUE INDEX IF NOT EXISTS "personal_access_tokens_token_unique" ON "personal_access_tokens" (token); +CREATE INDEX IF NOT EXISTS "personal_access_tokens_tokenable_type_tokenable_id_index" ON "personal_access_tokens" (tokenable_type, tokenable_id); +CREATE INDEX IF NOT EXISTS "idx_private_keys_team_id" ON "private_keys" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "private_keys_uuid_unique" ON "private_keys" (uuid); +CREATE INDEX IF NOT EXISTS "idx_projects_team_id" ON "projects" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "projects_uuid_unique" ON "projects" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "pushover_notification_settings_team_id_unique" ON "pushover_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "s3_storages_uuid_unique" ON "s3_storages" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backup_executions_uuid_unique" ON "scheduled_database_backup_executions" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_db_backup_executions_backup_id_created_at_index" ON "scheduled_database_backup_executions" (scheduled_database_backup_id, created_at); +CREATE INDEX IF NOT EXISTS "scheduled_database_backups_database_type_database_id_index" ON "scheduled_database_backups" (database_type, database_id); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backups_uuid_unique" ON "scheduled_database_backups" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_task_executions_task_id_created_at_index" ON "scheduled_task_executions" (scheduled_task_id, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_task_executions_uuid_unique" ON "scheduled_task_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_tasks_uuid_unique" ON "scheduled_tasks" (uuid); +CREATE INDEX IF NOT EXISTS "idx_servers_team_id" ON "servers" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "servers_uuid_unique" ON "servers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_applications_uuid_unique" ON "service_applications" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_databases_uuid_unique" ON "service_databases" (uuid); +CREATE INDEX IF NOT EXISTS "services_destination_type_destination_id_index" ON "services" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "services_uuid_unique" ON "services" (uuid); +CREATE INDEX IF NOT EXISTS "sessions_last_activity_index" ON "sessions" (last_activity); +CREATE INDEX IF NOT EXISTS "sessions_user_id_index" ON "sessions" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_environment_id_team_id_unique" ON "shared_environment_variables" (key, environment_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_project_id_team_id_unique" ON "shared_environment_variables" (key, project_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "slack_notification_settings_team_id_unique" ON "slack_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "standalone_clickhouses_destination_type_destination_id_index" ON "standalone_clickhouses" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_clickhouses_uuid_unique" ON "standalone_clickhouses" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_server_id_network_unique" ON "standalone_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_uuid_unique" ON "standalone_dockers" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_dragonflies_destination_type_destination_id_index" ON "standalone_dragonflies" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dragonflies_uuid_unique" ON "standalone_dragonflies" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_keydbs_destination_type_destination_id_index" ON "standalone_keydbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_keydbs_uuid_unique" ON "standalone_keydbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mariadbs_destination_type_destination_id_index" ON "standalone_mariadbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mariadbs_uuid_unique" ON "standalone_mariadbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mongodbs_destination_type_destination_id_index" ON "standalone_mongodbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mongodbs_uuid_unique" ON "standalone_mongodbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mysqls_destination_type_destination_id_index" ON "standalone_mysqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mysqls_uuid_unique" ON "standalone_mysqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_postgresqls_destination_type_destination_id_index" ON "standalone_postgresqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_postgresqls_uuid_unique" ON "standalone_postgresqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_redis_destination_type_destination_id_index" ON "standalone_redis" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_redis_uuid_unique" ON "standalone_redis" (uuid); +CREATE INDEX IF NOT EXISTS "idx_subscriptions_team_id" ON "subscriptions" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_server_id_network_unique" ON "swarm_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_uuid_unique" ON "swarm_dockers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "taggable_unique" ON "taggables" (tag_id, taggable_id, taggable_type); +CREATE UNIQUE INDEX IF NOT EXISTS "tags_uuid_unique" ON "tags" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_team_id_email_unique" ON "team_invitations" (team_id, email); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_uuid_unique" ON "team_invitations" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_user_team_id_user_id_unique" ON "team_user" (team_id, user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_notification_settings_team_id_unique" ON "telegram_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_batch_id_index" ON "telescope_entries" (batch_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_created_at_index" ON "telescope_entries" (created_at); +CREATE INDEX IF NOT EXISTS "telescope_entries_family_hash_index" ON "telescope_entries" (family_hash); +CREATE INDEX IF NOT EXISTS "telescope_entries_type_should_display_on_index_index" ON "telescope_entries" (type, should_display_on_index); +CREATE UNIQUE INDEX IF NOT EXISTS "telescope_entries_uuid_unique" ON "telescope_entries" (uuid); +CREATE INDEX IF NOT EXISTS "telescope_entries_tags_tag_index" ON "telescope_entries_tags" (tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_changelog_reads" (release_tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); + +-- Migration records +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (1, '2014_10_12_000000_create_users_table', 1); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (2, '2014_10_12_100000_create_password_reset_tokens_table', 2); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (3, '2014_10_12_200000_add_two_factor_columns_to_users_table', 3); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (4, '2018_08_08_100000_create_telescope_entries_table', 4); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (5, '2019_12_14_000001_create_personal_access_tokens_table', 5); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (6, '2023_03_20_112410_create_activity_log_table', 6); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (7, '2023_03_20_112411_add_event_column_to_activity_log_table', 7); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (8, '2023_03_20_112412_add_batch_uuid_column_to_activity_log_table', 8); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (9, '2023_03_20_112809_create_sessions_table', 9); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (10, '2023_03_20_112811_create_teams_table', 10); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (11, '2023_03_20_112812_create_team_user_table', 11); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (12, '2023_03_20_112813_create_team_invitations_table', 12); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (13, '2023_03_20_112814_create_instance_settings_table', 13); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (14, '2023_03_24_140711_create_servers_table', 14); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (15, '2023_03_24_140712_create_server_settings_table', 15); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (16, '2023_03_24_140853_create_private_keys_table', 16); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (17, '2023_03_27_075351_create_projects_table', 17); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (18, '2023_03_27_075443_create_project_settings_table', 18); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (19, '2023_03_27_075444_create_environments_table', 19); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (20, '2023_03_27_081716_create_applications_table', 20); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (21, '2023_03_27_081717_create_application_settings_table', 21); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (22, '2023_03_27_081718_create_application_previews_table', 22); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (23, '2023_03_27_083621_create_services_table', 23); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (24, '2023_03_27_085020_create_standalone_dockers_table', 24); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (25, '2023_03_27_085022_create_swarm_dockers_table', 25); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (26, '2023_03_28_062150_create_kubernetes_table', 26); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (27, '2023_03_28_083723_create_github_apps_table', 27); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (28, '2023_03_28_083726_create_gitlab_apps_table', 28); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (29, '2023_04_03_111012_create_local_persistent_volumes_table', 29); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (30, '2023_05_04_194548_create_environment_variables_table', 30); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (31, '2023_05_17_104039_create_failed_jobs_table', 31); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (32, '2023_05_24_083426_create_application_deployment_queues_table', 32); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (33, '2023_06_22_131459_move_wildcard_to_server', 33); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (34, '2023_06_23_084605_remove_wildcard_domain_from_instancesettings', 34); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (35, '2023_06_23_110548_next_channel_updates', 35); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (36, '2023_06_23_114131_change_env_var_value_length', 36); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (37, '2023_06_23_114132_remove_default_redirect_from_instance_settings', 37); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (38, '2023_06_23_114133_use_application_deployment_queues_as_activity', 38); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (39, '2023_06_23_114134_add_disk_usage_percentage_to_servers', 39); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (40, '2023_07_13_115117_create_subscriptions_table', 40); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (41, '2023_07_13_120719_create_webhooks_table', 41); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (42, '2023_07_13_120721_add_license_to_instance_settings', 42); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (43, '2023_07_27_182013_smtp_discord_schemaless_to_normal', 43); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (44, '2023_08_06_142951_add_description_field_to_applications_table', 44); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (45, '2023_08_06_142952_remove_foreignId_environment_variables', 45); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (46, '2023_08_06_142954_add_readonly_localpersistentvolumes', 46); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (47, '2023_08_07_073651_create_s3_storages_table', 47); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (48, '2023_08_07_142950_create_standalone_postgresqls_table', 48); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (49, '2023_08_08_150103_create_scheduled_database_backups_table', 49); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (50, '2023_08_10_113306_create_scheduled_database_backup_executions_table', 50); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (51, '2023_08_10_201311_add_backup_notifications_to_teams', 51); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (52, '2023_08_11_190528_add_dockerfile_to_applications_table', 52); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (53, '2023_08_15_095902_create_waitlists_table', 53); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (54, '2023_08_15_111125_update_users_table', 54); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (55, '2023_08_15_111126_update_servers_add_unreachable_count_table', 55); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (56, '2023_08_22_071048_add_boarding_to_teams', 56); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (57, '2023_08_22_071049_update_webhooks_type', 57); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (58, '2023_08_22_071050_update_subscriptions_stripe', 58); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (59, '2023_08_22_071051_add_stripe_plan_to_subscriptions', 59); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (60, '2023_08_22_071052_add_resend_as_email', 60); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (61, '2023_08_22_071053_add_resend_as_email_to_teams', 61); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (62, '2023_08_22_071054_add_stripe_reasons', 62); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (63, '2023_08_22_071055_add_discord_notifications_to_teams', 63); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (64, '2023_08_22_071056_update_telegram_notifications', 64); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (65, '2023_08_22_071057_add_nixpkgsarchive_to_applications', 65); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (66, '2023_08_22_071058_add_nixpkgsarchive_to_applications_remove', 66); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (67, '2023_08_22_071059_add_stripe_trial_ended', 67); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (68, '2023_08_22_071060_change_invitation_link_length', 68); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (69, '2023_09_20_082541_update_services_table', 69); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (70, '2023_09_20_082733_create_service_databases_table', 70); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (71, '2023_09_20_082737_create_service_applications_table', 71); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (72, '2023_09_20_083549_update_environment_variables_table', 72); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (73, '2023_09_22_185356_create_local_file_volumes_table', 73); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (74, '2023_09_23_111808_update_servers_with_cloudflared', 74); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (75, '2023_09_23_111809_remove_destination_from_services_table', 75); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (76, '2023_09_23_111811_update_service_applications_table', 76); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (77, '2023_09_23_111812_update_service_databases_table', 77); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (78, '2023_09_23_111813_update_users_databases_table', 78); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (79, '2023_09_23_111814_update_local_file_volumes_table', 79); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (80, '2023_09_23_111815_add_healthcheck_disable_to_apps_table', 80); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (81, '2023_09_23_111816_add_destination_to_services_table', 81); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (82, '2023_09_23_111817_use_instance_email_settings_by_default', 82); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (83, '2023_09_23_111818_set_notifications_on_by_default', 83); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (84, '2023_09_23_111819_add_server_emails', 84); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (85, '2023_10_08_111819_add_server_unreachable_count', 85); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (86, '2023_10_10_100320_update_s3_storages_table', 86); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (87, '2023_10_10_113144_add_dockerfile_location_applications_table', 87); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (88, '2023_10_12_132430_create_standalone_redis_table', 88); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (89, '2023_10_12_132431_add_standalone_redis_to_environment_variables_table', 89); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (90, '2023_10_12_132432_add_database_selection_to_backups', 90); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (91, '2023_10_18_072519_add_custom_labels_applications_table', 91); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (92, '2023_10_19_101331_create_standalone_mongodbs_table', 92); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (93, '2023_10_19_101332_add_standalone_mongodb_to_environment_variables_table', 93); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (94, '2023_10_24_103548_create_standalone_mysqls_table', 94); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (95, '2023_10_24_120523_create_standalone_mariadbs_table', 95); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (96, '2023_10_24_120524_add_standalone_mysql_to_environment_variables_table', 96); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (97, '2023_10_24_124934_add_is_shown_once_to_environment_variables_table', 97); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (98, '2023_11_01_100437_add_restart_to_deployment_queue', 98); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (99, '2023_11_07_123731_add_target_build_dockerfile', 99); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (100, '2023_11_08_112815_add_custom_config_standalone_postgresql', 100); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (101, '2023_11_09_133332_add_public_port_to_service_databases', 101); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (102, '2023_11_12_180605_change_fqdn_to_longer_field', 102); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (103, '2023_11_13_133059_add_sponsorship_disable', 103); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (104, '2023_11_14_103450_add_manual_webhook_secret', 104); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (105, '2023_11_14_121416_add_git_type', 105); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (106, '2023_11_16_101819_add_high_disk_usage_notification', 106); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (107, '2023_11_16_220647_add_log_drains', 107); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (108, '2023_11_17_160437_add_drain_log_enable_by_service', 108); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (109, '2023_11_20_094628_add_gpu_settings', 109); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (110, '2023_11_21_121920_add_additional_destinations_to_apps', 110); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (111, '2023_11_24_080341_add_docker_compose_location', 111); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (112, '2023_11_28_143533_add_fields_to_swarm_dockers', 112); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (113, '2023_11_29_075937_change_swarm_properties', 113); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (114, '2023_12_01_091723_save_logs_view_settings', 114); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (115, '2023_12_01_095356_add_custom_fluentd_config_for_logdrains', 115); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (116, '2023_12_08_162228_add_soft_delete_services', 116); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (117, '2023_12_11_103611_add_realtime_connection_problem', 117); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (118, '2023_12_13_110214_add_soft_deletes', 118); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (119, '2023_12_17_155616_add_custom_docker_compose_start_command', 119); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (120, '2023_12_18_093514_add_swarm_related_things', 120); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (121, '2023_12_19_124111_add_swarm_cluster_grouping', 121); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (122, '2023_12_30_134507_add_description_to_environments', 122); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (123, '2023_12_31_173041_create_scheduled_tasks_table', 123); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (124, '2024_01_01_231053_create_scheduled_task_executions_table', 124); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (125, '2024_01_02_113855_add_raw_compose_deployment', 125); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (126, '2024_01_12_123422_update_cpuset_limits', 126); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (127, '2024_01_15_084609_add_custom_dns_server', 127); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (128, '2024_01_16_115005_add_build_server_enable', 128); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (129, '2024_01_21_130328_add_docker_network_to_services', 129); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (130, '2024_01_23_095832_add_manual_webhook_secret_bitbucket', 130); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (131, '2024_01_23_113129_create_shared_environment_variables_table', 131); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (132, '2024_01_24_095449_add_concurrent_number_of_builds_per_server', 132); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (133, '2024_01_25_073212_add_server_id_to_queues', 133); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (134, '2024_01_27_164724_add_application_name_and_deployment_url_to_queue', 134); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (135, '2024_01_29_072322_change_env_variable_length', 135); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (136, '2024_01_29_145200_add_custom_docker_run_options', 136); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (137, '2024_02_01_111228_create_tags_table', 137); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (138, '2024_02_05_105215_add_destination_to_app_deployments', 138); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (139, '2024_02_06_132748_add_additional_destinations', 139); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (140, '2024_02_08_075523_add_post_deployment_to_applications', 140); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (141, '2024_02_08_112304_add_dynamic_timeout_for_deployments', 141); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (142, '2024_02_15_101921_add_consistent_application_container_name', 142); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (143, '2024_02_15_192025_add_is_gzip_enabled_to_services', 143); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (144, '2024_02_20_165045_add_permissions_to_github_app', 144); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (145, '2024_02_22_090900_add_only_this_server_deployment', 145); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (146, '2024_02_23_143119_add_custom_server_limits_to_teams_ultimate', 146); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (147, '2024_02_25_222150_add_server_force_disabled_field', 147); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (148, '2024_03_04_092244_add_gzip_enabled_and_stripprefix_settings', 148); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (149, '2024_03_07_115054_add_notifications_notification_disable', 149); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (150, '2024_03_08_180457_nullable_password', 150); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (151, '2024_03_11_150013_create_oauth_settings', 151); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (152, '2024_03_14_214402_add_multiline_envs', 152); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (153, '2024_03_18_101440_add_version_of_envs', 153); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (154, '2024_03_22_080914_remove_popup_notifications', 154); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (155, '2024_03_26_122110_remove_realtime_notifications', 155); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (156, '2024_03_28_114620_add_watch_paths_to_apps', 156); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (157, '2024_04_09_095517_make_custom_docker_commands_longer', 157); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (158, '2024_04_10_071920_create_standalone_keydbs_table', 158); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (159, '2024_04_10_082220_create_standalone_dragonflies_table', 159); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (160, '2024_04_10_091519_create_standalone_clickhouses_table', 160); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (161, '2024_04_10_124015_add_permission_local_file_volumes', 161); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (162, '2024_04_12_092337_add_config_hash_to_other_resources', 162); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (163, '2024_04_15_094703_add_literal_variables', 163); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (164, '2024_04_16_083919_add_service_type_on_creation', 164); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (165, '2024_04_17_132541_add_rollback_queues', 165); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (166, '2024_04_25_073615_add_docker_network_to_application_settings', 166); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (167, '2024_04_29_111956_add_custom_hc_indicator_apps', 167); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (168, '2024_05_06_093236_add_custom_name_to_application_settings', 168); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (169, '2024_05_07_124019_add_server_metrics', 169); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (170, '2024_05_10_085215_make_stripe_comment_longer', 170); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (171, '2024_05_15_091757_add_commit_message_to_app_deployment_queue', 171); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (172, '2024_05_15_151236_add_container_escape_toggle', 172); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (173, '2024_05_17_082012_add_env_sorting_toggle', 173); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (174, '2024_05_21_125739_add_scheduled_tasks_notification_to_teams', 174); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (175, '2024_05_22_103942_change_pre_post_deployment_commands_length_in_applications', 175); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (176, '2024_05_23_091713_add_gitea_webhook_to_applications', 176); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (177, '2024_06_05_101019_add_docker_compose_pr_domains', 177); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (178, '2024_06_06_103938_change_pr_issue_commend_id_type', 178); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (179, '2024_06_11_081614_add_www_non_www_redirect', 179); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (180, '2024_06_18_105948_move_server_metrics', 180); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (181, '2024_06_20_102551_add_server_api_sentinel', 181); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (182, '2024_06_21_143358_add_api_deployment_type', 182); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (183, '2024_06_22_081140_alter_instance_settings_add_instance_name', 183); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (184, '2024_06_25_184323_update_db', 184); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (185, '2024_07_01_115528_add_is_api_allowed_and_iplist', 185); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (186, '2024_07_05_120217_remove_unique_from_tag_names', 186); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (187, '2024_07_11_083719_application_compose_versions', 187); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (188, '2024_07_17_123828_add_is_container_labels_readonly', 188); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (189, '2024_07_18_110424_create_application_settings_is_preserve_repository_enabled', 189); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (190, '2024_07_18_123458_add_force_cleanup_server', 190); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (191, '2024_07_19_132617_disable_healtcheck_by_default', 191); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (192, '2024_07_23_112710_add_validation_logs_to_servers', 192); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (193, '2024_08_05_142659_add_update_frequency_settings', 193); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (194, '2024_08_07_155324_add_proxy_label_chooser', 194); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (195, '2024_08_09_215659_add_server_cleanup_fields_to_server_settings_table', 195); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (196, '2024_08_12_131659_add_local_file_volume_based_on_git', 196); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (197, '2024_08_12_155023_add_timezone_to_server_and_instance_settings', 197); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (198, '2024_08_14_183120_add_order_to_environment_variables_table', 198); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (199, '2024_08_15_115907_add_build_server_id_to_deployment_queue', 199); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (200, '2024_08_16_105649_add_custom_docker_options_to_dbs', 200); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (201, '2024_08_27_090528_add_compose_parsing_version_to_services', 201); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (202, '2024_09_05_085700_add_helper_version_to_instance_settings', 202); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (203, '2024_09_06_062534_change_server_cleanup_to_forced', 203); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (204, '2024_09_07_185402_change_cleanup_schedule', 204); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (205, '2024_09_08_130756_update_server_settings_default_timezone', 205); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (206, '2024_09_16_111428_encrypt_existing_private_keys', 206); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (207, '2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table', 207); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (208, '2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table', 208); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (209, '2024_09_26_083441_disable_api_by_default', 209); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (210, '2024_10_03_095427_add_dump_all_to_standalone_postgresqls', 210); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (211, '2024_10_10_081444_remove_constraint_from_service_applications_fqdn', 211); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (212, '2024_10_11_114331_add_required_env_variables', 212); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (213, '2024_10_14_090416_update_metrics_token_in_server_settings', 213); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (214, '2024_10_15_172139_add_is_shared_to_environment_variables', 214); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (215, '2024_10_16_120026_move_redis_password_to_envs', 215); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (216, '2024_10_16_192133_add_confirmation_settings_to_instance_settings_table', 216); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (217, '2024_10_17_093722_add_soft_delete_to_servers', 217); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (218, '2024_10_22_105745_add_server_disk_usage_threshold', 218); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (219, '2024_10_22_121223_add_server_disk_usage_notification', 219); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (220, '2024_10_29_093927_add_is_sentinel_debug_enabled_to_server_settings', 220); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (221, '2024_10_30_074601_rename_token_permissions', 221); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (222, '2024_11_02_213214_add_last_online_at_to_resources', 222); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (223, '2024_11_11_125335_add_custom_nginx_configuration_to_static', 223); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (224, '2024_11_11_125366_add_index_to_activity_log', 224); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (225, '2024_11_22_124742_add_uuid_to_environments_table', 225); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (226, '2024_12_05_091823_add_disable_build_cache_advanced_option', 226); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (227, '2024_12_05_212355_create_email_notification_settings_table', 227); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (228, '2024_12_05_212416_create_discord_notification_settings_table', 228); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (229, '2024_12_05_212440_create_telegram_notification_settings_table', 229); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (230, '2024_12_05_212546_migrate_email_notification_settings_from_teams_table', 230); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (231, '2024_12_05_212631_migrate_discord_notification_settings_from_teams_table', 231); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (232, '2024_12_05_212705_migrate_telegram_notification_settings_from_teams_table', 232); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (233, '2024_12_06_142014_create_slack_notification_settings_table', 233); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (234, '2024_12_09_105711_drop_waitlists_table', 234); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (235, '2024_12_10_122142_encrypt_instance_settings_email_columns', 235); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (236, '2024_12_10_122143_drop_resale_license', 236); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (237, '2024_12_11_135026_create_pushover_notification_settings_table', 237); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (238, '2024_12_11_161418_add_authentik_base_url_to_oauth_settings_table', 238); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (239, '2024_12_13_103007_encrypt_resend_api_key_in_instance_settings', 239); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (240, '2024_12_16_134437_add_resourceable_columns_to_environment_variables_table', 240); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (241, '2024_12_17_140637_add_server_disk_usage_check_frequency_to_server_settings_table', 241); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (242, '2024_12_23_142402_update_email_encryption_values', 242); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (243, '2025_01_05_050736_add_network_aliases_to_applications_table', 243); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (244, '2025_01_08_154008_switch_up_readonly_labels', 244); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (245, '2025_01_10_135244_add_horizon_job_details_to_queue', 245); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (246, '2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table', 246); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (247, '2025_01_15_130416_create_docker_cleanup_executions_table', 247); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (248, '2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues', 248); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (249, '2025_01_16_130238_add_finished_at_to_executions_tables', 249); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (250, '2025_01_21_125205_update_finished_at_timestamps_if_not_set', 250); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (251, '2025_01_22_101105_remove_wrongly_created_envs', 251); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (252, '2025_01_27_102616_add_ssl_fields_to_database_tables', 252); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (253, '2025_01_27_153741_create_ssl_certificates_table', 253); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (254, '2025_01_30_125223_encrypt_local_file_volumes_fields', 254); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (255, '2025_02_27_125249_add_index_to_scheduled_task_executions', 255); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (256, '2025_03_01_112617_add_stripe_past_due', 256); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (257, '2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions', 257); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (258, '2025_03_21_104103_disable_discord_here', 258); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (259, '2025_03_26_104103_disable_mongodb_ssl_by_default', 259); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (260, '2025_03_29_204400_revert_some_local_volume_encryption', 260); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (261, '2025_03_31_124212_add_specific_spa_configuration', 261); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (262, '2025_04_01_124212_stripe_comment_nullable', 262); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (263, '2025_04_17_110026_add_application_http_basic_auth_fields', 263); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (264, '2025_04_30_134146_add_is_migrated_to_services', 264); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (265, '2025_05_26_100258_add_server_patch_notifications', 265); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (266, '2025_05_29_100258_add_terminal_enabled_to_server_settings', 266); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (267, '2025_06_06_073345_create_server_previous_ip', 267); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (268, '2025_06_16_123532_change_sentinel_on_by_default', 268); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (269, '2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table', 269); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (270, '2025_06_26_131350_optimize_activity_log_indexes', 270); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (271, '2025_07_14_191016_add_deleted_at_to_application_previews_table', 271); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (272, '2025_07_16_202201_add_timeout_to_scheduled_database_backups_table', 272); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (273, '2025_08_07_142403_create_user_changelog_reads_table', 273); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (274, '2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table', 274); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (275, '2025_08_18_104146_add_email_change_fields_to_users_table', 275); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (276, '2025_08_18_154244_change_env_sorting_default_to_false', 276); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (277, '2025_08_21_080234_add_git_shallow_clone_to_application_settings_table', 277); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (278, '2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings', 278); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (279, '2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table', 279); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (280, '2025_09_10_173300_drop_webhooks_table', 280); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (281, '2025_09_10_173402_drop_kubernetes_table', 281); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (282, '2025_09_11_143432_remove_is_build_time_from_environment_variables_table', 282); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (283, '2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table', 283); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (284, '2025_09_17_081112_add_use_build_secrets_to_application_settings', 284); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (285, '2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table', 285); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (286, '2025_10_03_154100_update_clickhouse_image', 286); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (287, '2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table', 287); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (288, '2025_10_08_181125_create_cloud_provider_tokens_table', 288); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (289, '2025_10_08_185203_add_hetzner_server_id_to_servers_table', 289); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (290, '2025_10_09_095905_add_cloud_provider_token_id_to_servers_table', 290); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (291, '2025_10_09_113602_add_hetzner_server_status_to_servers_table', 291); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (292, '2025_10_09_125036_add_is_validating_to_servers_table', 292); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (293, '2025_11_02_161923_add_dev_helper_version_to_instance_settings', 293); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (294, '2025_11_09_000001_add_timeout_to_scheduled_tasks_table', 294); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (295, '2025_11_09_000002_improve_scheduled_task_executions_tracking', 295); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (296, '2025_11_10_112500_add_restart_tracking_to_applications_table', 296); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (297, '2025_11_12_130931_add_traefik_version_tracking_to_servers_table', 297); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (298, '2025_11_12_131252_add_traefik_outdated_to_email_notification_settings', 298); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (299, '2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings', 299); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (300, '2025_11_14_114632_add_traefik_outdated_info_to_servers_table', 300); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (301, '2025_11_16_000001_create_webhook_notification_settings_table', 301); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (302, '2025_11_16_000002_create_cloud_init_scripts_table', 302); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (303, '2025_11_17_092707_add_traefik_outdated_to_notification_settings', 303); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (304, '2025_11_18_083747_cleanup_dockerfile_data_for_non_dockerfile_buildpacks', 304); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (305, '2025_11_26_124200_add_build_cache_settings_to_application_settings', 305); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (306, '2025_11_28_000001_migrate_clickhouse_to_official_image', 306); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (307, '2025_12_04_134435_add_deployment_queue_limit_to_server_settings', 307); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (308, '2025_12_05_000000_add_docker_images_to_keep_to_application_settings', 308); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (309, '2025_12_05_100000_add_disable_application_image_retention_to_server_settings', 309); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (310, '2025_12_08_135600_add_performance_indexes', 310); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_135600_add_uuid_to_cloud_provider_tokens', 311); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e4c6b8b1..acc84b61a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,8 @@ services: volumes: - .:/var/www/html/:cached - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify postgres: pull_policy: always ports: diff --git a/package-lock.json b/package-lock.json index 6244197fb..59d678c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", @@ -2338,6 +2339,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index dc4b912ea..81cd8c9a4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml index 38adfdb6f..6716b6b84 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,17 +7,20 @@ ./tests/Feature + + ./tests/v4 + - - - - - - + + + + + + diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 0cf1a2772..f7ab57a14 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,7 +1,9 @@ environment('local') ? $localValue : ''); +if (! function_exists('getOldOrLocal')) { + function getOldOrLocal($key, $localValue) + { + return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : ''); + } } $name = getOldOrLocal('name', 'test3 normal user'); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 3c5773d0e..5f53721fb 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 545d9c62e..bcecf06c5 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/tests/Pest.php b/tests/Pest.php index 619dea153..cec77b86f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ use Illuminate\Support\Once; | need to change it using the "uses()" function to bind a different classes or traits. | */ -uses(Tests\TestCase::class)->in('Feature'); +uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); /* |-------------------------------------------------------------------------- diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php new file mode 100644 index 000000000..d5eb5034d --- /dev/null +++ b/tests/v4/Browser/LoginTest.php @@ -0,0 +1,46 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/login'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account'); +}); + +it('can login with valid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->assertSee('Login'); +}); + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records'); +}); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php new file mode 100644 index 000000000..ab1149e60 --- /dev/null +++ b/tests/v4/Browser/RegistrationTest.php @@ -0,0 +1,62 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/register'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account'); +}); + +it('can register a new root user', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'Password1!@') + ->click('Create Account') + ->assertPathIs('/onboarding'); + + expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); +}); + +it('fails registration with mismatched passwords', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'DifferentPass1!@') + ->click('Create Account') + ->assertSee('password'); +}); + +it('fails registration with weak password', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'short') + ->fill('password_confirmation', 'short') + ->click('Create Account') + ->assertSee('password'); +}); + +it('shows login link when a user already exists', function () { + User::factory()->create(['id' => 0]); + + $page = visit('/register'); + + $page->assertSee('Already registered?') + ->assertDontSee('Root User Setup'); +}); diff --git a/tests/v4/Feature/SqliteDatabaseTest.php b/tests/v4/Feature/SqliteDatabaseTest.php new file mode 100644 index 000000000..d2df5c0a6 --- /dev/null +++ b/tests/v4/Feature/SqliteDatabaseTest.php @@ -0,0 +1,18 @@ +getDriverName())->toBe('sqlite'); +}); + +it('runs migrations successfully', function () { + expect(Schema::hasTable('users'))->toBeTrue(); + expect(Schema::hasTable('teams'))->toBeTrue(); + expect(Schema::hasTable('servers'))->toBeTrue(); + expect(Schema::hasTable('applications'))->toBeTrue(); +}); From 6dea1ab0f3e706a065adc5a8d6557766abb52a4f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:37:40 +0100 Subject: [PATCH 08/35] test: add dashboard test and improve browser test coverage - Add DashboardTest with tests for project/server visibility - Add screenshots to existing browser tests for debugging - Skip onboarding in dev mode for faster testing - Update gitignore to exclude screenshot directories --- .gitignore | 2 + bootstrap/helpers/shared.php | 4 + tests/Browser/screenshots/.gitignore | 2 - tests/v4/Browser/DashboardTest.php | 162 ++++++++++++++++++++++++++ tests/v4/Browser/LoginTest.php | 12 +- tests/v4/Browser/RegistrationTest.php | 15 ++- 6 files changed, 187 insertions(+), 10 deletions(-) delete mode 100644 tests/Browser/screenshots/.gitignore create mode 100644 tests/v4/Browser/DashboardTest.php diff --git a/.gitignore b/.gitignore index 935ea548e..403028761 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules .DS_Store CHANGELOG.md /.workspaces +tests/Browser/Screenshots +tests/v4/Browser/Screenshots diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2173e7619..4372ff955 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -167,6 +167,10 @@ function currentTeam() function showBoarding(): bool { + if (isDev()) { + return false; + } + if (Auth::user()?->isMember()) { return false; } diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/Browser/screenshots/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php new file mode 100644 index 000000000..b4a97f268 --- /dev/null +++ b/tests/v4/Browser/DashboardTest.php @@ -0,0 +1,162 @@ + 0]); + + $this->user = User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + PrivateKey::create([ + 'id' => 1, + 'uuid' => 'ssh-test', + 'team_id' => 0, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); + + Server::create([ + 'id' => 0, + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'production-1', + 'name' => 'production-web', + 'description' => 'Production web server cluster', + 'ip' => '10.0.0.1', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'staging-1', + 'name' => 'staging-server', + 'description' => 'Staging environment server', + 'ip' => '10.0.0.2', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Project::create([ + 'uuid' => 'project-1', + 'name' => 'My first project', + 'description' => 'This is a test project in development', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-2', + 'name' => 'Production API', + 'description' => 'Backend services for production', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-3', + 'name' => 'Staging Environment', + 'description' => 'Staging and QA testing', + 'team_id' => 0, + ]); +}); + +function loginAndSkipOnboarding(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->click('Skip Setup'); +} + +it('redirects to login when not authenticated', function () { + $page = visit('/'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('shows onboarding after first login', function () { + $page = visit('/login'); + + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->assertSee("Let's go!") + ->assertSee('Skip Setup') + ->screenshot(); +}); + +it('shows dashboard after skipping onboarding', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Dashboard') + ->assertSee('Your self-hosted infrastructure.') + ->screenshot(); +}); + +it('shows all projects on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertSee('This is a test project in development') + ->assertSee('Production API') + ->assertSee('Backend services for production') + ->assertSee('Staging Environment') + ->assertSee('Staging and QA testing') + ->screenshot(); +}); + +it('shows servers on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Servers') + ->assertSee('localhost') + ->assertSee('This is a test docker container in development mode') + ->assertSee('production-web') + ->assertSee('Production web server cluster') + ->assertSee('staging-server') + ->assertSee('Staging environment server') + ->screenshot(); +}); diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php index d5eb5034d..7666e07e2 100644 --- a/tests/v4/Browser/LoginTest.php +++ b/tests/v4/Browser/LoginTest.php @@ -15,7 +15,8 @@ it('shows registration page when no users exist', function () { $page = visit('/login'); $page->assertSee('Root User Setup') - ->assertSee('Create Account'); + ->assertSee('Create Account') + ->screenshot(); }); it('can login with valid credentials', function () { @@ -27,7 +28,11 @@ it('can login with valid credentials', function () { $page = visit('/login'); - $page->assertSee('Login'); + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->screenshot(); }); it('fails login with invalid credentials', function () { @@ -42,5 +47,6 @@ it('fails login with invalid credentials', function () { $page->fill('email', 'random@email.com') ->fill('password', 'wrongpassword123') ->click('Login') - ->assertSee('These credentials do not match our records'); + ->assertSee('These credentials do not match our records') + ->screenshot(); }); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index ab1149e60..e2a232357 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -14,7 +14,8 @@ it('shows registration page when no users exist', function () { $page = visit('/register'); $page->assertSee('Root User Setup') - ->assertSee('Create Account'); + ->assertSee('Create Account') + ->screenshot(); }); it('can register a new root user', function () { @@ -25,7 +26,8 @@ it('can register a new root user', function () { ->fill('password', 'Password1!@') ->fill('password_confirmation', 'Password1!@') ->click('Create Account') - ->assertPathIs('/onboarding'); + ->assertPathIs('/onboarding') + ->screenshot(); expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); }); @@ -38,7 +40,8 @@ it('fails registration with mismatched passwords', function () { ->fill('password', 'Password1!@') ->fill('password_confirmation', 'DifferentPass1!@') ->click('Create Account') - ->assertSee('password'); + ->assertSee('password') + ->screenshot(); }); it('fails registration with weak password', function () { @@ -49,7 +52,8 @@ it('fails registration with weak password', function () { ->fill('password', 'short') ->fill('password_confirmation', 'short') ->click('Create Account') - ->assertSee('password'); + ->assertSee('password') + ->screenshot(); }); it('shows login link when a user already exists', function () { @@ -58,5 +62,6 @@ it('shows login link when a user already exists', function () { $page = visit('/register'); $page->assertSee('Already registered?') - ->assertDontSee('Root User Setup'); + ->assertDontSee('Root User Setup') + ->screenshot(); }); From 4ec32290cfcd4e46562d444d94f477b18fbfb2af Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:10:59 +0100 Subject: [PATCH 09/35] fix(server): improve IP uniqueness validation with team-specific error messages - Refactor server IP duplicate detection to use `first()` instead of `get()->count()` - Add team-scoped validation to distinguish between same-team and cross-team IP conflicts - Update error messages to clarify ownership: "already exists in your team" vs "in use by another team" - Apply consistent validation logic across API, boarding, and server management flows - Add comprehensive test suite for IP uniqueness enforcement across teams --- .../Controllers/Api/ServersController.php | 10 +- app/Livewire/Boarding/Index.php | 6 +- app/Livewire/Server/New/ByIp.php | 11 +- app/Livewire/Server/Show.php | 12 +- templates/service-templates-latest.json | 34 +++--- templates/service-templates.json | 34 +++--- tests/Feature/ServerIpUniquenessTest.php | 110 ++++++++++++++++++ 7 files changed, 169 insertions(+), 48 deletions(-) create mode 100644 tests/Feature/ServerIpUniquenessTest.php diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ee5455b6..a29839d14 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -519,9 +519,13 @@ class ServersController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $allServers = ModelsServer::whereIp($request->ip)->get(); - if ($allServers->count() > 0) { - return response()->json(['message' => 'Server with this IP already exists.'], 400); + $foundServer = ModelsServer::whereIp($request->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === $teamId) { + return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); + } + + return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..0f6f45d83 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -283,7 +283,11 @@ class Index extends Component $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..eecdfb4d0 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -97,10 +97,13 @@ class ByIp extends Component $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..83c63a81c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -189,12 +189,16 @@ class Show extends Component $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 3c5773d0e..5f53721fb 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 545d9c62e..bcecf06c5 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php new file mode 100644 index 000000000..705b1eddc --- /dev/null +++ b/tests/Feature/ServerIpUniquenessTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); +}); + +it('detects duplicate ip within the same team', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->toBe($this->team->id); +}); + +it('detects duplicate ip from another team', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->not->toBe($this->team->id); +}); + +it('shows correct error message for same team duplicate in boarding', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain already exists in your team.'); +}); + +it('shows correct error message for other team duplicate in boarding', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain is already in use by another team.'); +}); + +it('allows adding ip that does not exist globally', function () { + $foundServer = Server::whereIp('10.20.30.40')->first(); + + expect($foundServer)->toBeNull(); +}); + +it('enforces global uniqueness not just team-scoped', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '9.8.7.6', + 'team_id' => $otherTeam->id, + ]); + + // Global check finds the server even though it belongs to another team + $foundServer = Server::whereIp('9.8.7.6')->first(); + expect($foundServer)->not->toBeNull(); + + // Team-scoped check would miss it - this is why global check is needed + $teamScopedServer = Server::where('team_id', $this->team->id) + ->where('ip', '9.8.7.6') + ->first(); + expect($teamScopedServer)->toBeNull(); +}); From 1b2c03fc2d8789c902ed22e3d14ca3473b7f668a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:28:52 +0100 Subject: [PATCH 10/35] chore: prepare for PR --- app/Jobs/ServerConnectionCheckJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..2625a26ae 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -107,6 +107,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function checkHetznerStatus(): void { + $status = null; + try { $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); From a34d1656f46f48f6c2e529c3e52b3fce01830b08 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:42:58 +0100 Subject: [PATCH 11/35] chore: prepare for PR --- app/Jobs/ServerCheckJob.php | 14 ++++++++++++++ app/Jobs/ServerConnectionCheckJob.php | 19 ++++++++++++++++++- app/Jobs/ServerStorageCheckJob.php | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 2ac92e72d..a18d45b9a 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..47d58b53e 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -101,7 +101,24 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue 'is_usable' => false, ]); - throw $e; + return; + } + } + + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerConnectionCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); } } diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9d45491c6..51426d880 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; @@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced public function __construct(public Server $server, public int|string|null $percentage = null) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerStorageCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { From e9323e35502a553e287fb969ae054b384de2243b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:43:08 +0100 Subject: [PATCH 12/35] chore: prepare for PR --- app/Jobs/PushServerUpdateJob.php | 3 + tests/Feature/PushServerUpdateJobTest.php | 76 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/Feature/PushServerUpdateJobTest.php diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index c02a7e3c5..5d018cf19 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); + if (empty($subId)) { + continue; + } if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php new file mode 100644 index 000000000..d508d58ab --- /dev/null +++ b/tests/Feature/PushServerUpdateJobTest.php @@ -0,0 +1,76 @@ +create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => '', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + + // Run handle - should not throw a PDOException about empty bigint + $job->handle(); + + // The empty subId container should have been skipped + expect($job->foundServiceApplicationIds)->not->toContain(''); + expect($job->serviceContainerStatuses)->toBeEmpty(); +}); + +test('containers with valid service subId are processed', function () { + $server = Server::factory()->create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) $serviceApp->id, + 'com.docker.compose.service' => 'myapp', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); +}); From b7480fbe38c14406252c7cb382457a79af4534d0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 13/35] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 54 ++++++++++++++++++--- tests/Feature/StartDatabaseProxyTest.php | 45 +++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/StartDatabaseProxyTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c634f14ba..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -112,12 +112,52 @@ class StartDatabaseProxy $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + + try { + instant_remote_process([ + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; } } diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php new file mode 100644 index 000000000..c62569866 --- /dev/null +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -0,0 +1,45 @@ +create(); + + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'is_public' => true, + 'public_port' => 5432, + ]); + + expect($database->is_public)->toBeTrue(); + + $action = new StartDatabaseProxy; + + // Use reflection to test the private method directly + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue(); + expect($method->invoke($action, 'address already in use'))->toBeTrue(); + expect($method->invoke($action, 'some other error'))->toBeFalse(); +}); + +test('isNonTransientError detects port conflict patterns', function () { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'address already in use'))->toBeTrue() + ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'network timeout'))->toBeFalse() + ->and($method->invoke($action, 'connection refused'))->toBeFalse(); +}); From ced1938d43302de6b1636e39b8bf9f41d2d2528d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:48:01 +0100 Subject: [PATCH 14/35] chore: prepare for PR --- app/Traits/SshRetryable.php | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..4366558ef 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -145,24 +145,21 @@ trait SshRetryable } try { - app('sentry')->captureMessage( - 'SSH connection retry triggered', - \Sentry\Severity::warning(), - [ - 'extra' => [ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ], - 'tags' => [ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ], - ] - ); + \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { + $scope->setExtras([ + 'attempt' => $attempt, + 'max_retries' => $maxRetries, + 'delay_seconds' => $delay, + 'error_message' => $errorMessage, + 'context' => $context, + 'retryable_error' => true, + ]); + $scope->setTags([ + 'component' => 'ssh_retry', + 'error_type' => 'connection_retry', + ]); + \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); + }); } catch (\Throwable $e) { // Don't let Sentry tracking errors break the SSH retry flow Log::warning('Failed to track SSH retry event in Sentry', [ From c3f0ed30989b1cc6f1bc2d976fab88a9feefb763 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:00:27 +0100 Subject: [PATCH 15/35] refactor(ssh-retry): remove Sentry tracking from retry logic Remove the trackSshRetryEvent() method and its invocation from the SSH retry flow. This simplifies the retry mechanism and reduces external dependencies for retry handling. --- app/Traits/SshRetryable.php | 38 ------------------------------------- 1 file changed, 38 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 4366558ef..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ trait SshRetryable if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { $delay = $this->calculateRetryDelay($attempt); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); - // Add deployment log if available (for ExecuteRemoteCommand trait) if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); @@ -133,39 +130,4 @@ trait SshRetryable return null; } - - /** - * Track SSH retry event in Sentry - */ - protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void - { - // Only track in production/cloud instances - if (isDev() || ! config('constants.sentry.sentry_dsn')) { - return; - } - - try { - \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { - $scope->setExtras([ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ]); - $scope->setTags([ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ]); - \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); - }); - } catch (\Throwable $e) { - // Don't let Sentry tracking errors break the SSH retry flow - Log::warning('Failed to track SSH retry event in Sentry', [ - 'error' => $e->getMessage(), - 'original_attempt' => $attempt, - ]); - } - } } From 76a770911c32f390b08f01ce244753ae108aee60 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:10:59 +0100 Subject: [PATCH 16/35] fix(server): improve IP uniqueness validation with team-specific error messages - Refactor server IP duplicate detection to use `first()` instead of `get()->count()` - Add team-scoped validation to distinguish between same-team and cross-team IP conflicts - Update error messages to clarify ownership: "already exists in your team" vs "in use by another team" - Apply consistent validation logic across API, boarding, and server management flows - Add comprehensive test suite for IP uniqueness enforcement across teams --- .../Controllers/Api/ServersController.php | 10 +- app/Livewire/Boarding/Index.php | 6 +- app/Livewire/Server/New/ByIp.php | 11 +- app/Livewire/Server/Show.php | 12 +- tests/Feature/ServerIpUniquenessTest.php | 110 ++++++++++++++++++ 5 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/ServerIpUniquenessTest.php diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ee5455b6..a29839d14 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -519,9 +519,13 @@ class ServersController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $allServers = ModelsServer::whereIp($request->ip)->get(); - if ($allServers->count() > 0) { - return response()->json(['message' => 'Server with this IP already exists.'], 400); + $foundServer = ModelsServer::whereIp($request->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === $teamId) { + return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); + } + + return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..0f6f45d83 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -283,7 +283,11 @@ class Index extends Component $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..eecdfb4d0 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -97,10 +97,13 @@ class ByIp extends Component $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..83c63a81c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -189,12 +189,16 @@ class Show extends Component $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php new file mode 100644 index 000000000..705b1eddc --- /dev/null +++ b/tests/Feature/ServerIpUniquenessTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); +}); + +it('detects duplicate ip within the same team', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->toBe($this->team->id); +}); + +it('detects duplicate ip from another team', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->not->toBe($this->team->id); +}); + +it('shows correct error message for same team duplicate in boarding', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain already exists in your team.'); +}); + +it('shows correct error message for other team duplicate in boarding', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain is already in use by another team.'); +}); + +it('allows adding ip that does not exist globally', function () { + $foundServer = Server::whereIp('10.20.30.40')->first(); + + expect($foundServer)->toBeNull(); +}); + +it('enforces global uniqueness not just team-scoped', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '9.8.7.6', + 'team_id' => $otherTeam->id, + ]); + + // Global check finds the server even though it belongs to another team + $foundServer = Server::whereIp('9.8.7.6')->first(); + expect($foundServer)->not->toBeNull(); + + // Team-scoped check would miss it - this is why global check is needed + $teamScopedServer = Server::where('team_id', $this->team->id) + ->where('ip', '9.8.7.6') + ->first(); + expect($teamScopedServer)->toBeNull(); +}); From ce29dce9e79d9809f6577c3ffb665233fe1b5f94 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:28:52 +0100 Subject: [PATCH 17/35] chore: prepare for PR --- app/Jobs/ServerConnectionCheckJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..2625a26ae 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -107,6 +107,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function checkHetznerStatus(): void { + $status = null; + try { $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); From 4a40009020cc67ef9311ef378bff5ea782e1e1c9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:42:58 +0100 Subject: [PATCH 18/35] chore: prepare for PR --- app/Jobs/ServerCheckJob.php | 14 ++++++++++++++ app/Jobs/ServerConnectionCheckJob.php | 19 ++++++++++++++++++- app/Jobs/ServerStorageCheckJob.php | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 2ac92e72d..a18d45b9a 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 2625a26ae..d4a499865 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -101,7 +101,24 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue 'is_usable' => false, ]); - throw $e; + return; + } + } + + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerConnectionCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); } } diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9d45491c6..51426d880 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; @@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced public function __construct(public Server $server, public int|string|null $percentage = null) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerStorageCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { From b40926e915471fc8dc7ddea1a9b9e7a253391972 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:43:08 +0100 Subject: [PATCH 19/35] chore: prepare for PR --- app/Jobs/PushServerUpdateJob.php | 3 + tests/Feature/PushServerUpdateJobTest.php | 76 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/Feature/PushServerUpdateJobTest.php diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index c02a7e3c5..5d018cf19 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); + if (empty($subId)) { + continue; + } if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php new file mode 100644 index 000000000..d508d58ab --- /dev/null +++ b/tests/Feature/PushServerUpdateJobTest.php @@ -0,0 +1,76 @@ +create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => '', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + + // Run handle - should not throw a PDOException about empty bigint + $job->handle(); + + // The empty subId container should have been skipped + expect($job->foundServiceApplicationIds)->not->toContain(''); + expect($job->serviceContainerStatuses)->toBeEmpty(); +}); + +test('containers with valid service subId are processed', function () { + $server = Server::factory()->create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) $serviceApp->id, + 'com.docker.compose.service' => 'myapp', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); +}); From 1519666d4cbaea76e864b135c73bfb10d842391e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 20/35] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 54 ++++++++++++++++++--- tests/Feature/StartDatabaseProxyTest.php | 45 +++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/StartDatabaseProxyTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c634f14ba..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -112,12 +112,52 @@ class StartDatabaseProxy $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + + try { + instant_remote_process([ + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; } } diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php new file mode 100644 index 000000000..c62569866 --- /dev/null +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -0,0 +1,45 @@ +create(); + + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'is_public' => true, + 'public_port' => 5432, + ]); + + expect($database->is_public)->toBeTrue(); + + $action = new StartDatabaseProxy; + + // Use reflection to test the private method directly + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue(); + expect($method->invoke($action, 'address already in use'))->toBeTrue(); + expect($method->invoke($action, 'some other error'))->toBeFalse(); +}); + +test('isNonTransientError detects port conflict patterns', function () { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'address already in use'))->toBeTrue() + ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'network timeout'))->toBeFalse() + ->and($method->invoke($action, 'connection refused'))->toBeFalse(); +}); From da0e06a97e4cf219586c073529840685cfb4aa80 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:48:01 +0100 Subject: [PATCH 21/35] chore: prepare for PR --- app/Traits/SshRetryable.php | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..4366558ef 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -145,24 +145,21 @@ trait SshRetryable } try { - app('sentry')->captureMessage( - 'SSH connection retry triggered', - \Sentry\Severity::warning(), - [ - 'extra' => [ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ], - 'tags' => [ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ], - ] - ); + \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { + $scope->setExtras([ + 'attempt' => $attempt, + 'max_retries' => $maxRetries, + 'delay_seconds' => $delay, + 'error_message' => $errorMessage, + 'context' => $context, + 'retryable_error' => true, + ]); + $scope->setTags([ + 'component' => 'ssh_retry', + 'error_type' => 'connection_retry', + ]); + \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); + }); } catch (\Throwable $e) { // Don't let Sentry tracking errors break the SSH retry flow Log::warning('Failed to track SSH retry event in Sentry', [ From 211ab3704533ef4f1d2ace2847f2a649c2e85c84 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:00:27 +0100 Subject: [PATCH 22/35] refactor(ssh-retry): remove Sentry tracking from retry logic Remove the trackSshRetryEvent() method and its invocation from the SSH retry flow. This simplifies the retry mechanism and reduces external dependencies for retry handling. --- app/Traits/SshRetryable.php | 38 ------------------------------------- 1 file changed, 38 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 4366558ef..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ trait SshRetryable if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { $delay = $this->calculateRetryDelay($attempt); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); - // Add deployment log if available (for ExecuteRemoteCommand trait) if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); @@ -133,39 +130,4 @@ trait SshRetryable return null; } - - /** - * Track SSH retry event in Sentry - */ - protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void - { - // Only track in production/cloud instances - if (isDev() || ! config('constants.sentry.sentry_dsn')) { - return; - } - - try { - \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { - $scope->setExtras([ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ]); - $scope->setTags([ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ]); - \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); - }); - } catch (\Throwable $e) { - // Don't let Sentry tracking errors break the SSH retry flow - Log::warning('Failed to track SSH retry event in Sentry', [ - 'error' => $e->getMessage(), - 'original_attempt' => $attempt, - ]); - } - } } From b56688978271d8891bf42ded146b01b2a0690922 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:14:11 +0100 Subject: [PATCH 23/35] merge fix --- app/Actions/Database/StartDatabaseProxy.php | 52 ++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..bebc056c5 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -113,14 +113,16 @@ class StartDatabaseProxy $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); + try { + try { instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { $database->update(['is_public' => false]); @@ -144,6 +146,44 @@ class StartDatabaseProxy } } + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} was disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + private function isNonTransientError(string $message): bool { $nonTransientPatterns = [ From f05b7106cfb75bb1702e33110939037fc9eaecd0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 24/35] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 52 +++------------------ 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index bebc056c5..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -113,16 +113,14 @@ class StartDatabaseProxy $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - try { - try { instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { $database->update(['is_public' => false]); @@ -146,44 +144,6 @@ class StartDatabaseProxy } } - private function isNonTransientError(string $message): bool - { - $nonTransientPatterns = [ - 'port is already allocated', - 'address already in use', - 'Bind for', - ]; - - foreach ($nonTransientPatterns as $pattern) { - if (str_contains($message, $pattern)) { - return true; - } - } - - return false; - } catch (\RuntimeException $e) { - if ($this->isNonTransientError($e->getMessage())) { - $database->update(['is_public' => false]); - - $team = data_get($database, 'environment.project.team') - ?? data_get($database, 'service.environment.project.team'); - - $team?->notify( - new \App\Notifications\Container\ContainerRestarted( - "TCP Proxy for {$database->name} was disabled due to error: {$e->getMessage()}", - $server, - ) - ); - - ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); - - return; - } - - throw $e; - } - } - private function isNonTransientError(string $message): bool { $nonTransientPatterns = [ From 25ccde83fa8d4a4565db69aace28681b59ba308e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:04:05 +0100 Subject: [PATCH 25/35] fix(api): add a newline to openapi.json --- app/Console/Commands/Generate/OpenApi.php | 3 ++- openapi.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php index 2b266c258..224c10792 100644 --- a/app/Console/Commands/Generate/OpenApi.php +++ b/app/Console/Commands/Generate/OpenApi.php @@ -32,7 +32,8 @@ class OpenApi extends Command echo $process->output(); $yaml = file_get_contents('openapi.yaml'); - $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT); + + $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n"; file_put_contents('openapi.json', $json); echo "Converted OpenAPI YAML to JSON.\n"; } diff --git a/openapi.json b/openapi.json index cbd79ca1d..f5da0883f 100644 --- a/openapi.json +++ b/openapi.json @@ -11300,4 +11300,4 @@ "description": "Teams" } ] -} \ No newline at end of file +} From 8c6c2703cc9ea961e1b0bdd43d2ddc1fb4430527 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Mon, 16 Feb 2026 22:26:58 +0300 Subject: [PATCH 26/35] feat: expose scheduled tasks to API --- .../Api/ScheduledTasksController.php | 362 ++++++++++++++++++ app/Models/ScheduledTask.php | 17 + routes/api.php | 7 + 3 files changed, 386 insertions(+) create mode 100644 app/Http/Controllers/Api/ScheduledTasksController.php diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php new file mode 100644 index 000000000..d9972a364 --- /dev/null +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -0,0 +1,362 @@ +makeHidden([ + 'id', + 'team_id', + 'application_id', + 'service_id', + 'standalone_postgresql_id', + ]); + + return serializeApiResponse($task); + } + + public function create_scheduled_task(Request $request, Application|Service $resource) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = new ScheduledTask(); + $data = $request->all(); + $task->fill($data); + $task->team_id = $teamId; + + if ($resource instanceof Application) { + $task->application_id = $resource->id; + } elseif ($resource instanceof Service) { + $task->service_id = $resource->id; + } + + $task->save(); + + return response()->json($this->removeSensitiveData($task), 201); + } + + #[OA\Get( + summary: 'List (Application)', + description: 'List all scheduled tasks for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for an application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $tasks = $application->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + #[OA\Post( + summary: 'Create (Application)', + description: 'Create a new scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->create_scheduled_task($request, $application); + } + + #[OA\Get( + summary: 'List (Service)', + description: 'List all scheduled tasks for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for a service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $tasks = $service->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + #[OA\Post( + summary: 'Create (Service)', + description: 'Create a new scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->create_scheduled_task($request, $service); + } +} diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index bada0b7a5..f4b021f27 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -5,7 +5,24 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task model', + type: 'object', + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], + ], +)] class ScheduledTask extends BaseModel { use HasSafeStringAttribute; diff --git a/routes/api.php b/routes/api.php index 8ff1fd1cc..fb91baa84 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; +use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; @@ -171,6 +172,12 @@ Route::group([ Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + + Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); Route::group([ From edc92d7edcca25030575241ca55cf62c18ef0d1f Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 00:56:40 +0300 Subject: [PATCH 27/35] feat(api): add OpenAPI for managing scheduled tasks for applications and services --- openapi.json | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++ openapi.yaml | 225 +++++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+) diff --git a/openapi.json b/openapi.json index f5da0883f..0c4a3240e 100644 --- a/openapi.json +++ b/openapi.json @@ -3433,6 +3433,143 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Tasks", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -9967,6 +10104,143 @@ ] } }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List (Service)", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create (Service)", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -10967,6 +11241,55 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, "Service": { "description": "Service model", "properties": { diff --git a/openapi.yaml b/openapi.yaml index 172607117..d329ec644 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2163,6 +2163,100 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Tasks' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true, + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -6231,6 +6325,100 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List (Service)' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create (Service)' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: @@ -6944,6 +7132,43 @@ components: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object Service: description: 'Service model' properties: From a5d48c54da5320fab892b53e8877c739d1f23d71 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 01:33:46 +0300 Subject: [PATCH 28/35] feat(api): add delete endpoints for scheduled tasks in applications and services --- .../Api/ScheduledTasksController.php | 162 +++++++++++++++++- openapi.json | 118 +++++++++++++ openapi.yaml | 80 +++++++++ routes/api.php | 2 + 4 files changed, 358 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index d9972a364..4778dec2a 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -77,7 +77,7 @@ class ScheduledTasksController extends Controller } #[OA\Get( - summary: 'List (Application)', + summary: 'List Task', description: 'List all scheduled tasks for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-application-uuid', @@ -140,7 +140,7 @@ class ScheduledTasksController extends Controller } #[OA\Post( - summary: 'Create (Application)', + summary: 'Create Task', description: 'Create a new scheduled task for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'create-scheduled-task-by-application-uuid', @@ -218,8 +218,85 @@ class ScheduledTasksController extends Controller return $this->create_scheduled_task($request, $application); } + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $task = $application->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } + #[OA\Get( - summary: 'List (Service)', + summary: 'List Tasks', description: 'List all scheduled tasks for a service.', path: '/services/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-service-uuid', @@ -282,7 +359,7 @@ class ScheduledTasksController extends Controller } #[OA\Post( - summary: 'Create (Service)', + summary: 'Create Task', description: 'Create a new scheduled task for a service.', path: '/services/{uuid}/scheduled-tasks', operationId: 'create-scheduled-task-by-service-uuid', @@ -359,4 +436,81 @@ class ScheduledTasksController extends Controller return $this->create_scheduled_task($request, $service); } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $task = $service->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } } diff --git a/openapi.json b/openapi.json index 0c4a3240e..7df13e4ae 100644 --- a/openapi.json +++ b/openapi.json @@ -3570,6 +3570,65 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -10241,6 +10300,65 @@ ] } }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index d329ec644..ac684c98a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2257,6 +2257,46 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Scheduled task deleted.' + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -6419,6 +6459,46 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Scheduled task deleted.' + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /teams: get: tags: diff --git a/routes/api.php b/routes/api.php index fb91baa84..7f9e0d3be 100644 --- a/routes/api.php +++ b/routes/api.php @@ -175,9 +175,11 @@ Route::group([ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); Route::group([ From 2b913a1c35f62cc6c1d53d018999911267f420d1 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 02:18:08 +0300 Subject: [PATCH 29/35] feat(api): add update endpoints for scheduled tasks in applications and services --- .../Api/ScheduledTasksController.php | 220 ++++++++++++++++++ openapi.json | 188 +++++++++++++++ openapi.yaml | 132 +++++++++++ routes/api.php | 2 + 4 files changed, 542 insertions(+) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 4778dec2a..13fe21a4a 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -76,6 +76,52 @@ class ScheduledTasksController extends Controller return response()->json($this->removeSensitiveData($task), 201); } + public function update_scheduled_task(Request $request, Application|Service $resource) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => 'string|max:255', + 'command' => 'string', + 'frequency' => 'string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $data = $request->all(); + $task->update($data); + + return response()->json($this->removeSensitiveData($task), 200); + } + #[OA\Get( summary: 'List Task', description: 'List all scheduled tasks for an application.', @@ -295,6 +341,93 @@ class ScheduledTasksController extends Controller return response()->json(['message' => 'Scheduled task deleted.']); } + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->update_scheduled_task($request, $application); + } + #[OA\Get( summary: 'List Tasks', description: 'List all scheduled tasks for a service.', @@ -513,4 +646,91 @@ class ScheduledTasksController extends Controller return response()->json(['message' => 'Scheduled task deleted.']); } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->update_scheduled_task($request, $service); + } } diff --git a/openapi.json b/openapi.json index 7df13e4ae..ef71fb5da 100644 --- a/openapi.json +++ b/openapi.json @@ -3571,6 +3571,100 @@ } }, "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "delete": { "tags": [ "Scheduled Tasks" @@ -10301,6 +10395,100 @@ } }, "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "delete": { "tags": [ "Scheduled Tasks" diff --git a/openapi.yaml b/openapi.yaml index ac684c98a..99cc84dcb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2258,6 +2258,72 @@ paths: - bearerAuth: [] '/applications/{uuid}/scheduled-tasks/{task_uuid}': + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] delete: tags: - 'Scheduled Tasks' @@ -6460,6 +6526,72 @@ paths: - bearerAuth: [] '/services/{uuid}/scheduled-tasks/{task_uuid}': + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] delete: tags: - 'Scheduled Tasks' diff --git a/routes/api.php b/routes/api.php index 7f9e0d3be..9c4d703a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -175,10 +175,12 @@ Route::group([ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); From b49069f3fc82712527b135a06a7016d9fa0abfd4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:04:10 +0100 Subject: [PATCH 30/35] feat(docker): install PHP sockets extension in development environment Add socket.io/php-socket dependency to support WebSocket functionality in the development container. Also update package-lock.json to reflect peer dependency configurations for Socket.IO packages. --- docker/development/Dockerfile | 3 +++ package-lock.json | 18 +++++++++--------- templates/service-templates-latest.json | 8 +++----- templates/service-templates.json | 8 +++----- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index ab9cb2fca..98b4d2006 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -47,6 +47,9 @@ RUN apk add --no-cache \ lsof \ vim +# Install PHP extensions +RUN install-php-extensions sockets + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/package-lock.json b/package-lock.json index 59d678c4f..d9a7aa7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -950,7 +950,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1403,8 +1404,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1557,6 +1557,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1571,6 +1572,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2331,7 +2333,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2452,7 +2453,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2557,6 +2557,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2601,8 +2602,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2654,7 +2654,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2754,7 +2753,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2777,6 +2775,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2798,6 +2797,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 5f53721fb..6ee9094df 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", diff --git a/templates/service-templates.json b/templates/service-templates.json index bcecf06c5..8aab00daf 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", From 967d2959633617235624387f7f4f619fa3c76fc9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:20:32 +0100 Subject: [PATCH 31/35] chore: prepare for PR --- app/Livewire/GlobalSearch.php | 1 + public/svgs/spacebot.png | Bin 0 -> 134688 bytes .../views/livewire/global-search.blade.php | 49 +++++++++++------- templates/compose/spacebot.yaml | 31 +++++++++++ templates/service-templates-latest.json | 19 +++++++ templates/service-templates.json | 19 +++++++ 6 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 public/svgs/spacebot.png create mode 100644 templates/compose/spacebot.yaml diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index ed9115093..f910110dc 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1495,6 +1495,7 @@ class GlobalSearch extends Component 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', + 'logo' => data_get($service, 'logo'), ]); } diff --git a/public/svgs/spacebot.png b/public/svgs/spacebot.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec1da70223b2e1287357b0766b645f9535fb6d4 GIT binary patch literal 134688 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

HV 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

-
- - - -
+ @if (! empty($item['logo'])) +
+ {{ $item['name'] }} +
+ @else +
+ + + +
+ @endif
@@ -808,15 +814,22 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ +
Date: Wed, 18 Feb 2026 11:53:58 +0100 Subject: [PATCH 32/35] fix(api): improve scheduled tasks API with auth, validation, and execution endpoints - Add authorization checks ($this->authorize) for all read/write operations - Use customApiValidator() instead of Validator::make() to match codebase patterns - Add extra field rejection to prevent mass assignment - Use Application::ownedByCurrentTeamAPI() for consistent query patterns - Remove non-existent standalone_postgresql_id from hidden fields - Add execution listing endpoints for both applications and services - Add ScheduledTaskExecution OpenAPI schema - Use $request->only() instead of $request->all() for safe updates - Add ScheduledTaskFactory and feature tests Co-Authored-By: Claude Opus 4.6 --- .../Api/ScheduledTasksController.php | 444 +++-- app/Models/ScheduledTask.php | 5 + app/Models/ScheduledTaskExecution.php | 16 + database/factories/ScheduledTaskFactory.php | 20 + openapi.json | 1429 +++++++++-------- openapi.yaml | 992 +++++++----- routes/api.php | 4 +- tests/Feature/ScheduledTaskApiTest.php | 365 +++++ 8 files changed, 2078 insertions(+), 1197 deletions(-) create mode 100644 database/factories/ScheduledTaskFactory.php create mode 100644 tests/Feature/ScheduledTaskApiTest.php diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 13fe21a4a..cf3574f1c 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -7,7 +7,6 @@ use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class ScheduledTasksController extends Controller @@ -19,25 +18,44 @@ class ScheduledTasksController extends Controller 'team_id', 'application_id', 'service_id', - 'standalone_postgresql_id', ]); return serializeApiResponse($task); } - public function create_scheduled_task(Request $request, Application|Service $resource) + private function resolveApplication(Request $request, int $teamId): ?Application { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + } + + private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $tasks = $resource->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = Validator::make($request->all(), [ + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', 'command' => 'required|string', 'frequency' => 'required|string', @@ -46,10 +64,18 @@ class ScheduledTasksController extends Controller 'enabled' => 'boolean', ]); - if ($validator->fails()) { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + return response()->json([ 'message' => 'Validation failed.', - 'errors' => $validator->errors(), + 'errors' => $errors, ], 422); } @@ -60,9 +86,15 @@ class ScheduledTasksController extends Controller ], 422); } - $task = new ScheduledTask(); - $data = $request->all(); - $task->fill($data); + $teamId = getTeamIdFromToken(); + + $task = new ScheduledTask; + $task->name = $request->name; + $task->command = $request->command; + $task->frequency = $request->frequency; + $task->container = $request->container; + $task->timeout = $request->timeout ?? 300; + $task->enabled = $request->enabled ?? true; $task->team_id = $teamId; if ($resource instanceof Application) { @@ -76,19 +108,18 @@ class ScheduledTasksController extends Controller return response()->json($this->removeSensitiveData($task), 201); } - public function update_scheduled_task(Request $request, Application|Service $resource) + private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } + $this->authorize('update', $resource); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = Validator::make($request->all(), [ + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'command' => 'string', 'frequency' => 'string', @@ -97,10 +128,18 @@ class ScheduledTasksController extends Controller 'enabled' => 'boolean', ]); - if ($validator->fails()) { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + return response()->json([ 'message' => 'Validation failed.', - 'errors' => $validator->errors(), + 'errors' => $errors, ], 422); } @@ -116,14 +155,45 @@ class ScheduledTasksController extends Controller return response()->json(['message' => 'Scheduled task not found.'], 404); } - $data = $request->all(); - $task->update($data); + $task->update($request->only($allowedFields)); return response()->json($this->removeSensitiveData($task), 200); } + private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } + + private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $executions = $task->executions()->get()->map(function ($execution) { + $execution->makeHidden(['id', 'scheduled_task_id']); + + return serializeApiResponse($execution); + }); + + return response()->json($executions); + } + #[OA\Get( - summary: 'List Task', + summary: 'List Tasks', description: 'List all scheduled tasks for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-application-uuid', @@ -166,23 +236,19 @@ class ScheduledTasksController extends Controller ), ] )] - public function scheduled_tasks_by_application_uuid(Request $request) + public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - $tasks = $application->scheduled_tasks->map(function ($task) { - return $this->removeSensitiveData($task); - }); - - return response()->json($tasks); + return $this->listTasks($application); } #[OA\Post( @@ -218,7 +284,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -249,19 +315,106 @@ class ScheduledTasksController extends Controller ), ] )] - public function create_scheduled_task_by_application_uuid(Request $request) + public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - return $this->create_scheduled_task($request, $application); + return $this->createTask($request, $application); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->updateTask($request, $application); } #[OA\Delete( @@ -319,33 +472,26 @@ class ScheduledTasksController extends Controller ), ] )] - public function delete_scheduled_task_by_application_uuid(Request $request) + public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - $task = $application->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { - return response()->json(['message' => 'Scheduled task not found.'], 404); - } - - $task->delete(); - - return response()->json(['message' => 'Scheduled task deleted.']); + return $this->deleteTask($request, $application); } - #[OA\Patch( - summary: 'Update Task', - description: 'Update a scheduled task for an application.', - path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', - operationId: 'update-scheduled-task-by-application-uuid', + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-application-uuid', security: [ ['bearerAuth' => []], ], @@ -370,32 +516,17 @@ class ScheduledTasksController extends Controller ) ), ], - requestBody: new OA\RequestBody( - description: 'Scheduled task data', - required: true, - content: new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema( - type: 'object', - properties: [ - 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], - 'command' => ['type' => 'string', 'description' => 'The command to execute.'], - 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], - 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], - ], - ), - ) - ), responses: [ new OA\Response( response: 200, - description: 'Scheduled task updated.', + description: 'Get all executions for a scheduled task.', content: [ new OA\MediaType( mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) ), ] ), @@ -407,25 +538,21 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), - new OA\Response( - response: 422, - ref: '#/components/responses/422', - ), ] )] - public function update_scheduled_task_by_application_uuid(Request $request) + public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - return $this->update_scheduled_task($request, $application); + return $this->getExecutions($request, $application); } #[OA\Get( @@ -472,23 +599,19 @@ class ScheduledTasksController extends Controller ), ] )] - public function scheduled_tasks_by_service_uuid(Request $request) + public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $tasks = $service->scheduled_tasks->map(function ($task) { - return $this->removeSensitiveData($task); - }); - - return response()->json($tasks); + return $this->listTasks($service); } #[OA\Post( @@ -524,7 +647,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -555,19 +678,106 @@ class ScheduledTasksController extends Controller ), ] )] - public function create_scheduled_task_by_service_uuid(Request $request) + public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - return $this->create_scheduled_task($request, $service); + return $this->createTask($request, $service); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->updateTask($request, $service); } #[OA\Delete( @@ -625,33 +835,26 @@ class ScheduledTasksController extends Controller ), ] )] - public function delete_scheduled_task_by_service_uuid(Request $request) + public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $task = $service->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { - return response()->json(['message' => 'Scheduled task not found.'], 404); - } - - $task->delete(); - - return response()->json(['message' => 'Scheduled task deleted.']); + return $this->deleteTask($request, $service); } - #[OA\Patch( - summary: 'Update Task', - description: 'Update a scheduled task for a service.', - path: '/services/{uuid}/scheduled-tasks/{task_uuid}', - operationId: 'update-scheduled-task-by-service-uuid', + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-service-uuid', security: [ ['bearerAuth' => []], ], @@ -676,32 +879,17 @@ class ScheduledTasksController extends Controller ) ), ], - requestBody: new OA\RequestBody( - description: 'Scheduled task data', - required: true, - content: new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema( - type: 'object', - properties: [ - 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], - 'command' => ['type' => 'string', 'description' => 'The command to execute.'], - 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], - 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], - ], - ), - ) - ), responses: [ new OA\Response( response: 200, - description: 'Scheduled task updated.', + description: 'Get all executions for a scheduled task.', content: [ new OA\MediaType( mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) ), ] ), @@ -713,24 +901,20 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), - new OA\Response( - response: 422, - ref: '#/components/responses/422', - ), ] )] - public function update_scheduled_task_by_service_uuid(Request $request) + public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - return $this->update_scheduled_task($request, $service); + return $this->getExecutions($request, $service); } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index f4b021f27..272638a81 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -29,6 +29,11 @@ class ScheduledTask extends BaseModel protected $guarded = []; + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); + } + protected function casts(): array { return [ diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 02fd6917a..c0601a4c9 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -3,7 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task Execution model', + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'], + 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'], + 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'], + 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'], + 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'], + 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'], + 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'], + ], +)] class ScheduledTaskExecution extends BaseModel { protected $guarded = []; diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php new file mode 100644 index 000000000..5f6519288 --- /dev/null +++ b/database/factories/ScheduledTaskFactory.php @@ -0,0 +1,20 @@ + fake()->word(), + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => 1, + ]; + } +} diff --git a/openapi.json b/openapi.json index ef71fb5da..d3bf08e30 100644 --- a/openapi.json +++ b/openapi.json @@ -3433,296 +3433,6 @@ ] } }, - "\/applications\/{uuid}\/scheduled-tasks": { - "get": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "List Tasks", - "description": "List all scheduled tasks for an application.", - "operationId": "list-scheduled-tasks-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get all scheduled tasks for an application.", - "content": { - "application\/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Create Tasks", - "description": "Create a new scheduled task for an application.", - "operationId": "create-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "required": [ - "name", - "command", - "frequency" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Scheduled task created.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { - "patch": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Update Task", - "description": "Update a scheduled task for an application.", - "operationId": "update-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Scheduled task updated.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Delete Task", - "description": "Delete a scheduled task for an application.", - "operationId": "delete-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scheduled task deleted.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Scheduled task deleted." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/cloud-tokens": { "get": { "tags": [ @@ -8339,6 +8049,698 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -10257,296 +10659,6 @@ ] } }, - "\/services\/{uuid}\/scheduled-tasks": { - "get": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "List (Service)", - "description": "List all scheduled tasks for a service.", - "operationId": "list-scheduled-tasks-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get all scheduled tasks for a service.", - "content": { - "application\/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Create (Service)", - "description": "Create a new scheduled task for a service.", - "operationId": "create-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "required": [ - "name", - "command", - "frequency" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Scheduled task created.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { - "patch": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Update Task", - "description": "Update a scheduled task for a service.", - "operationId": "update-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Scheduled task updated.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Delete Task", - "description": "Delete a scheduled task for a service.", - "operationId": "delete-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scheduled task deleted.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Scheduled task deleted." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/teams": { "get": { "tags": [ @@ -11351,6 +11463,110 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, "Server": { "description": "Server model", "properties": { @@ -11547,55 +11763,6 @@ }, "type": "object" }, - "ScheduledTask": { - "description": "Scheduled Task model", - "properties": { - "id": { - "type": "integer", - "description": "The unique identifier of the scheduled task in the database." - }, - "uuid": { - "type": "string", - "description": "The unique identifier of the scheduled task." - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled." - }, - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "The date and time when the scheduled task was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "The date and time when the scheduled task was last updated." - } - }, - "type": "object" - }, "Service": { "description": "Service model", "properties": { @@ -11912,6 +12079,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, { "name": "Private Keys", "description": "Private Keys" diff --git a/openapi.yaml b/openapi.yaml index 99cc84dcb..6fb548f9f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2163,206 +2163,6 @@ paths: security: - bearerAuth: [] - '/applications/{uuid}/scheduled-tasks': - get: - tags: - - 'Scheduled Tasks' - summary: 'List Tasks' - description: 'List all scheduled tasks for an application.' - operationId: list-scheduled-tasks-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - responses: - '200': - description: 'Get all scheduled tasks for an application.' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] - post: - tags: - - 'Scheduled Tasks' - summary: 'Create Tasks' - description: 'Create a new scheduled task for an application.' - operationId: create-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true, - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - required: - - name - - command - - frequency - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '201': - description: 'Scheduled task created.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - '/applications/{uuid}/scheduled-tasks/{task_uuid}': - patch: - tags: - - 'Scheduled Tasks' - summary: 'Update Task' - description: 'Update a scheduled task for an application.' - operationId: update-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '200': - description: 'Scheduled task updated.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - delete: - tags: - - 'Scheduled Tasks' - summary: 'Delete Task' - description: 'Delete a scheduled task for an application.' - operationId: delete-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - responses: - '200': - description: 'Scheduled task deleted.' - content: - application/json: - schema: - properties: - message: - type: string - example: 'Scheduled task deleted.' - type: object - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] /cloud-tokens: get: tags: @@ -5285,6 +5085,478 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on an application.' + operationId: list-scheduled-task-executions-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on a service.' + operationId: list-scheduled-task-executions-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -6431,206 +6703,6 @@ paths: security: - bearerAuth: [] - '/services/{uuid}/scheduled-tasks': - get: - tags: - - 'Scheduled Tasks' - summary: 'List (Service)' - description: 'List all scheduled tasks for a service.' - operationId: list-scheduled-tasks-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - responses: - '200': - description: 'Get all scheduled tasks for a service.' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] - post: - tags: - - 'Scheduled Tasks' - summary: 'Create (Service)' - description: 'Create a new scheduled task for a service.' - operationId: create-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - required: - - name - - command - - frequency - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '201': - description: 'Scheduled task created.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - '/services/{uuid}/scheduled-tasks/{task_uuid}': - patch: - tags: - - 'Scheduled Tasks' - summary: 'Update Task' - description: 'Update a scheduled task for a service.' - operationId: update-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '200': - description: 'Scheduled task updated.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - delete: - tags: - - 'Scheduled Tasks' - summary: 'Delete Task' - description: 'Delete a scheduled task for a service.' - operationId: delete-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - responses: - '200': - description: 'Scheduled task deleted.' - content: - application/json: - schema: - properties: - message: - type: string - example: 'Scheduled task deleted.' - type: object - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] /teams: get: tags: @@ -7207,6 +7279,86 @@ components: description: type: string type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object + ScheduledTaskExecution: + description: 'Scheduled Task Execution model' + properties: + uuid: + type: string + description: 'The unique identifier of the execution.' + status: + type: string + enum: + - success + - failed + - running + description: 'The status of the execution.' + message: + type: string + nullable: true + description: 'The output message of the execution.' + retry_count: + type: integer + description: 'The number of retries.' + duration: + type: number + nullable: true + description: 'Duration in seconds.' + started_at: + type: string + format: date-time + nullable: true + description: 'When the execution started.' + finished_at: + type: string + format: date-time + nullable: true + description: 'When the execution finished.' + created_at: + type: string + format: date-time + description: 'When the record was created.' + updated_at: + type: string + format: date-time + description: 'When the record was last updated.' + type: object Server: description: 'Server model' properties: @@ -7344,43 +7496,6 @@ components: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' type: object - ScheduledTask: - description: 'Scheduled Task model' - properties: - id: - type: integer - description: 'The unique identifier of the scheduled task in the database.' - uuid: - type: string - description: 'The unique identifier of the scheduled task.' - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - created_at: - type: string - format: date-time - description: 'The date and time when the scheduled task was created.' - updated_at: - type: string - format: date-time - description: 'The date and time when the scheduled task was last updated.' - type: object Service: description: 'Service model' properties: @@ -7598,6 +7713,9 @@ tags: - name: Resources description: Resources + - + name: 'Scheduled Tasks' + description: 'Scheduled Tasks' - name: 'Private Keys' description: 'Private Keys' diff --git a/routes/api.php b/routes/api.php index 9c4d703a9..c39f22c02 100644 --- a/routes/api.php +++ b/routes/api.php @@ -107,7 +107,7 @@ Route::group([ /** * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services. - */ + */ Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); @@ -177,11 +177,13 @@ Route::group([ Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); }); Route::group([ diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php new file mode 100644 index 000000000..fbd6e383e --- /dev/null +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -0,0 +1,365 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('returns empty array when no tasks exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns tasks when they exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Test Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Test Task']); + }); + + test('returns 404 for unknown application uuid', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('creates a task with valid data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Backup']); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + }); + + test('returns 422 when name is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 for invalid cron expression', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => 'not-a-cron', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.'); + }); + + test('returns 422 when extra fields are present', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'unknown_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('defaults timeout and enabled when not provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Test', + 'timeout' => 300, + 'enabled' => true, + ]); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('updates task with partial data', 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, + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['name' => 'New Name']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ + 'name' => 'Test', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('deletes task successfully', 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, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + + $this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () { + test('returns executions for a task', 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, + ]); + + ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'success', + 'message' => 'OK', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['status' => 'success']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); + + $response->assertStatus(404); + }); +}); + +describe('Service scheduled tasks API', function () { + test('can list tasks for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + 'name' => 'Service Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Service Task']); + }); + + test('can create a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ + 'name' => 'Service Backup', + 'command' => 'pg_dump', + 'frequency' => '0 2 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Service Backup']); + }); + + test('can delete a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + }); +}); From f0e93eadde55bcfdea4b476e76eb6dfaabe9600d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:22:35 +0100 Subject: [PATCH 33/35] chore: prepare for PR From 4d36265017fa47e546ee4dc12471c13f29981874 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:30:44 +0100 Subject: [PATCH 34/35] fix(api): improve scheduled tasks validation and delete logic - Use explicit has() checks for timeout and enabled fields to properly handle falsy values - Add validation to prevent empty update requests - Optimize delete endpoint to use direct query deletion instead of fetch-then-delete - Update factory to use Team::factory() for proper test isolation --- .../Controllers/Api/ScheduledTasksController.php | 14 ++++++++------ database/factories/ScheduledTaskFactory.php | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index cf3574f1c..6245dc2ec 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -93,8 +93,8 @@ class ScheduledTasksController extends Controller $task->command = $request->command; $task->frequency = $request->frequency; $task->container = $request->container; - $task->timeout = $request->timeout ?? 300; - $task->enabled = $request->enabled ?? true; + $task->timeout = $request->has('timeout') ? $request->timeout : 300; + $task->enabled = $request->has('enabled') ? $request->enabled : true; $task->team_id = $teamId; if ($resource instanceof Application) { @@ -117,6 +117,10 @@ class ScheduledTasksController extends Controller return $return; } + if ($request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; $validator = customApiValidator($request->all(), [ @@ -164,13 +168,11 @@ class ScheduledTasksController extends Controller { $this->authorize('update', $resource); - $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { + $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); + if (! $deleted) { return response()->json(['message' => 'Scheduled task not found.'], 404); } - $task->delete(); - return response()->json(['message' => 'Scheduled task deleted.']); } diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php index 5f6519288..6e4d6d740 100644 --- a/database/factories/ScheduledTaskFactory.php +++ b/database/factories/ScheduledTaskFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; class ScheduledTaskFactory extends Factory @@ -14,7 +15,7 @@ class ScheduledTaskFactory extends Factory 'frequency' => '* * * * *', 'timeout' => 300, 'enabled' => true, - 'team_id' => 1, + 'team_id' => Team::factory(), ]; } } From 664b31212fecaf464bf719df7f722e55262b1db8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:42:42 +0100 Subject: [PATCH 35/35] chore: prepare for PR --- app/Jobs/ScheduledJobManager.php | 115 ++++++-- app/Livewire/Settings/ScheduledJobs.php | 211 ++++++++++++++ app/Services/SchedulerLogParser.php | 188 +++++++++++++ .../components/settings/navbar.blade.php | 4 + .../settings/scheduled-jobs.blade.php | 260 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/ScheduledJobMonitoringTest.php | 200 ++++++++++++++ 7 files changed, 958 insertions(+), 22 deletions(-) create mode 100644 app/Livewire/Settings/ScheduledJobs.php create mode 100644 app/Services/SchedulerLogParser.php create mode 100644 resources/views/livewire/settings/scheduled-jobs.blade.php create mode 100644 tests/Feature/ScheduledJobMonitoringTest.php diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 75ff883c2..c9dc20af1 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -27,6 +27,10 @@ class ScheduledJobManager implements ShouldQueue */ private ?Carbon $executionTime = null; + private int $dispatchedCount = 0; + + private int $skippedCount = 0; + /** * Create a new job instance. */ @@ -61,6 +65,12 @@ class ScheduledJobManager implements ShouldQueue { // Freeze the execution time at the start of the job $this->executionTime = Carbon::now(); + $this->dispatchedCount = 0; + $this->skippedCount = 0; + + Log::channel('scheduled')->info('ScheduledJobManager started', [ + 'execution_time' => $this->executionTime->toIso8601String(), + ]); // Process backups - don't let failures stop task processing try { @@ -91,6 +101,13 @@ class ScheduledJobManager implements ShouldQueue 'trace' => $e->getTraceAsString(), ]); } + + Log::channel('scheduled')->info('ScheduledJobManager completed', [ + 'execution_time' => $this->executionTime->toIso8601String(), + 'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime), + 'dispatched' => $this->dispatchedCount, + 'skipped' => $this->skippedCount, + ]); } private function processScheduledBackups(): void @@ -101,8 +118,16 @@ class ScheduledJobManager implements ShouldQueue foreach ($backups as $backup) { try { - // Apply the same filtering logic as the original - if (! $this->shouldProcessBackup($backup)) { + $skipReason = $this->getBackupSkipReason($backup); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('backup', $skipReason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + continue; } @@ -120,6 +145,14 @@ class ScheduledJobManager implements ShouldQueue if ($this->shouldRunNow($frequency, $serverTimezone)) { DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing backup', [ @@ -138,7 +171,15 @@ class ScheduledJobManager implements ShouldQueue foreach ($tasks as $task) { try { - if (! $this->shouldProcessTask($task)) { + $skipReason = $this->getTaskSkipReason($task); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('task', $skipReason, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $task->server()?->team_id, + ]); + continue; } @@ -156,6 +197,13 @@ class ScheduledJobManager implements ShouldQueue if ($this->shouldRunNow($frequency, $serverTimezone)) { ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing task', [ @@ -166,33 +214,33 @@ class ScheduledJobManager implements ShouldQueue } } - private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool + private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); - return false; + return 'database_deleted'; } $server = $backup->server(); if (blank($server)) { $backup->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - return true; + return null; } - private function shouldProcessTask(ScheduledTask $task): bool + private function getTaskSkipReason(ScheduledTask $task): ?string { $service = $task->service; $application = $task->application; @@ -201,32 +249,32 @@ class ScheduledJobManager implements ShouldQueue if (blank($server)) { $task->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } if (! $service && ! $application) { $task->delete(); - return false; + return 'resource_deleted'; } if ($application && str($application->status)->contains('running') === false) { - return false; + return 'application_not_running'; } if ($service && str($service->status)->contains('running') === false) { - return false; + return 'service_not_running'; } - return true; + return null; } private function shouldRunNow(string $frequency, string $timezone): bool @@ -248,7 +296,15 @@ class ScheduledJobManager implements ShouldQueue foreach ($servers as $server) { try { - if (! $this->shouldProcessDockerCleanup($server)) { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); + continue; } @@ -270,6 +326,12 @@ class ScheduledJobManager implements ShouldQueue $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ @@ -296,19 +358,28 @@ class ScheduledJobManager implements ShouldQueue return $query->get(); } - private function shouldProcessDockerCleanup(Server $server): bool + private function getDockerCleanupSkipReason(Server $server): ?string { if (! $server->isFunctional()) { - return false; + return 'server_not_functional'; } // In cloud, check subscription status (except team 0) if (isCloud() && $server->team_id !== 0) { if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) { - return false; + return 'subscription_unpaid'; } } - return true; + return null; + } + + private function logSkip(string $type, string $reason, array $context = []): void + { + Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([ + 'type' => $type, + 'skip_reason' => $reason, + 'execution_time' => $this->executionTime?->toIso8601String(), + ], $context)); } } diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php new file mode 100644 index 000000000..66480cd8d --- /dev/null +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -0,0 +1,211 @@ +executions = collect(); + $this->skipLogs = collect(); + $this->managerRuns = collect(); + } + + public function mount(): void + { + if (! isInstanceAdmin()) { + redirect()->route('dashboard'); + + return; + } + + $this->loadData(); + } + + public function updatedFilterType(): void + { + $this->loadData(); + } + + public function updatedFilterDate(): void + { + $this->loadData(); + } + + public function refresh(): void + { + $this->loadData(); + } + + public function render() + { + return view('livewire.settings.scheduled-jobs', [ + 'executions' => $this->executions, + 'skipLogs' => $this->skipLogs, + 'managerRuns' => $this->managerRuns, + ]); + } + + private function loadData(?int $teamId = null): void + { + $this->executions = $this->getExecutions($teamId); + + $parser = new SchedulerLogParser; + $this->skipLogs = $parser->getRecentSkips(50, $teamId); + $this->managerRuns = $parser->getRecentRuns(30, $teamId); + } + + private function getExecutions(?int $teamId = null): Collection + { + $dateFrom = $this->getDateFrom(); + + $backups = collect(); + $tasks = collect(); + $cleanups = collect(); + + if ($this->filterType === 'all' || $this->filterType === 'backup') { + $backups = $this->getBackupExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'task') { + $tasks = $this->getTaskExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'cleanup') { + $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); + } + + return $backups->concat($tasks)->concat($cleanups) + ->sortByDesc('created_at') + ->values() + ->take(100); + } + + private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $backup = $execution->scheduledDatabaseBackup; + $database = $backup?->database; + $server = $backup?->server(); + + return [ + 'id' => $execution->id, + 'type' => 'backup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $database?->name ?? 'Deleted database', + 'resource_type' => $database ? class_basename($database) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $backup?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->updated_at, + 'message' => $execution->message, + 'size' => $execution->size ?? null, + ]; + }); + } + + private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, function ($q) use ($teamId) { + $q->where(function ($sub) use ($teamId) { + $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) + ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); + }); + }) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $task = $execution->scheduledTask; + $resource = $task?->application ?? $task?->service; + $server = $task?->server(); + $teamId = $server?->team_id; + + return [ + 'id' => $execution->id, + 'type' => 'task', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $task?->name ?? 'Deleted task', + 'resource_type' => $resource ? class_basename($resource) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $teamId, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = DockerCleanupExecution::with(['server']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $server = $execution->server; + + return [ + 'id' => $execution->id, + 'type' => 'cleanup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $server?->name ?? 'Deleted server', + 'resource_type' => 'Server', + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $server?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at ?? $execution->updated_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getDateFrom(): ?Carbon + { + return match ($this->filterDate) { + 'last_24h' => now()->subDay(), + 'last_7d' => now()->subWeek(), + 'last_30d' => now()->subMonth(), + default => null, + }; + } +} diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php new file mode 100644 index 000000000..a735a11c3 --- /dev/null +++ b/app/Services/SchedulerLogParser.php @@ -0,0 +1,188 @@ + + */ + public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $skips = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null || ! isset($entry['context']['skip_reason'])) { + continue; + } + + if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { + continue; + } + + $skips->push([ + 'timestamp' => $entry['timestamp'], + 'type' => $entry['context']['type'] ?? 'unknown', + 'reason' => $entry['context']['skip_reason'], + 'team_id' => $entry['context']['team_id'] ?? null, + 'context' => $entry['context'], + ]); + } + } + + return $skips->sortByDesc('timestamp')->values()->take($limit); + } + + /** + * Get recent manager execution logs (start/complete events). + * + * @return Collection + */ + public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $runs = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null) { + continue; + } + + if (! str_contains($entry['message'], 'ScheduledJobManager')) { + continue; + } + + $runs->push([ + 'timestamp' => $entry['timestamp'], + 'message' => $entry['message'], + 'duration_ms' => $entry['context']['duration_ms'] ?? null, + 'dispatched' => $entry['context']['dispatched'] ?? null, + 'skipped' => $entry['context']['skipped'] ?? null, + ]); + } + } + + return $runs->sortByDesc('timestamp')->values()->take($limit); + } + + private function getLogFiles(): array + { + $logDir = storage_path('logs'); + if (! File::isDirectory($logDir)) { + return []; + } + + $files = File::glob($logDir.'/scheduled-*.log'); + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + // Only check last 3 days of logs + return array_slice($files, 0, 3); + } + + /** + * @return array{timestamp: string, level: string, message: string, context: array}|null + */ + private function parseLogLine(string $line): ?array + { + // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} + if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { + return null; + } + + $timestamp = $matches[1]; + $level = $matches[2]; + $rest = $matches[3]; + + // Extract JSON context if present + $context = []; + if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { + $message = $contextMatches[1]; + $decoded = json_decode($contextMatches[2], true); + if (is_array($decoded)) { + $context = $decoded; + } + } else { + $message = $rest; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Efficiently read the last N lines of a file. + * + * @return string[] + */ + private function readLastLines(string $filePath, int $lines): array + { + if (! File::exists($filePath)) { + return []; + } + + $fileSize = File::size($filePath); + if ($fileSize === 0) { + return []; + } + + // For small files, read the whole thing + if ($fileSize < 1024 * 1024) { + $content = File::get($filePath); + + return array_filter(explode("\n", $content), fn ($line) => $line !== ''); + } + + // For large files, read from the end + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return []; + } + + $result = []; + $chunkSize = 8192; + $buffer = ''; + $position = $fileSize; + + while ($position > 0 && count($result) < $lines) { + $readSize = min($chunkSize, $position); + $position -= $readSize; + fseek($handle, $position); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines); + + $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } +} diff --git a/resources/views/components/settings/navbar.blade.php b/resources/views/components/settings/navbar.blade.php index 10df20e03..565e485d0 100644 --- a/resources/views/components/settings/navbar.blade.php +++ b/resources/views/components/settings/navbar.blade.php @@ -19,6 +19,10 @@ href="{{ route('settings.oauth') }}"> OAuth + + Scheduled Jobs +
diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php new file mode 100644 index 000000000..d22aca911 --- /dev/null +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -0,0 +1,260 @@ +
+ + Scheduled Job Issues | Coolify + + +
+
+
+

Scheduled Job Issues

+ Refresh +
+
Shows failed executions, skipped jobs, and scheduler health.
+
+ + {{-- Tab Buttons --}} +
+
+ Failures ({{ $executions->count() }}) +
+
+ Scheduler Runs ({{ $managerRuns->count() }}) +
+
+ Skipped Jobs ({{ $skipLogs->count() }}) +
+
+ + {{-- Executions Tab --}} +
+ {{-- Filters --}} +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + @forelse($executions as $execution) + + + + + + + + + @empty + + + + @endforelse + +
TypeResourceServerStartedDurationMessage
+ @php + $typeLabel = match($execution['type']) { + 'backup' => 'Backup', + 'task' => 'Task', + 'cleanup' => 'Cleanup', + default => ucfirst($execution['type']), + }; + $typeBg = match($execution['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ $typeLabel }} + + + {{ $execution['resource_name'] }} + @if($execution['resource_type']) + ({{ $execution['resource_type'] }}) + @endif + {{ $execution['server_name'] }} + {{ $execution['created_at']->diffForHumans() }} + {{ $execution['created_at']->format('M d H:i') }} + + @if($execution['finished_at'] && $execution['created_at']) + {{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s + @elseif($execution['status'] === 'running') + + @else + - + @endif + + {{ \Illuminate\Support\Str::limit($execution['message'], 80) }} +
+ No failures found for the selected filters. +
+
+
+ + {{-- Scheduler Runs Tab --}} +
+
Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.
+
+ + + + + + + + + + + + @forelse($managerRuns as $run) + + + + + + + + @empty + + + + @endforelse + +
TimeEventDurationDispatchedSkipped
{{ $run['timestamp'] }}{{ $run['message'] }} + @if($run['duration_ms'] !== null) + {{ $run['duration_ms'] }}ms + @else + - + @endif + {{ $run['dispatched'] ?? '-' }} + @if(($run['skipped'] ?? 0) > 0) + {{ $run['skipped'] }} + @else + {{ $run['skipped'] ?? '-' }} + @endif +
+ No scheduler run logs found. Logs appear after the ScheduledJobManager runs. +
+
+
+ + {{-- Skipped Jobs Tab --}} +
+
Jobs that were not dispatched because conditions were not met.
+
+ + + + + + + + + + + @forelse($skipLogs as $skip) + + + + + + + @empty + + + + @endforelse + +
TimeTypeReasonDetails
{{ $skip['timestamp'] }} + @php + $skipTypeBg = match($skip['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ ucfirst(str_replace('_', ' ', $skip['type'])) }} + + + @php + $reasonLabel = match($skip['reason']) { + 'server_not_functional' => 'Server not functional', + 'subscription_unpaid' => 'Subscription unpaid', + 'database_deleted' => 'Database deleted', + 'server_deleted' => 'Server deleted', + 'resource_deleted' => 'Resource deleted', + 'application_not_running' => 'Application not running', + 'service_not_running' => 'Service not running', + default => ucfirst(str_replace('_', ' ', $skip['reason'])), + }; + $reasonBg = match($skip['reason']) { + 'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400', + 'subscription_unpaid' => 'text-warning', + 'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400', + default => '', + }; + @endphp + {{ $reasonLabel }} + + @php + $details = collect($skip['context']) + ->except(['type', 'skip_reason', 'execution_time']) + ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v) + ->implode(', '); + @endphp + {{ $details }} +
+ No skipped jobs found. This means all scheduled jobs passed their conditions. +
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index e8c738b71..b6c6c95ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -62,6 +62,7 @@ use App\Livewire\Server\Show as ServerShow; use App\Livewire\Server\Swarm as ServerSwarm; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; +use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; use App\Livewire\Settings\Updates as SettingsUpdates; use App\Livewire\SettingsBackup; use App\Livewire\SettingsEmail; @@ -119,6 +120,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php new file mode 100644 index 000000000..1348375d4 --- /dev/null +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -0,0 +1,200 @@ +rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + $this->rootUser = User::factory()->create(); + $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']); + + // Create regular team and user + $this->regularTeam = Team::factory()->create(); + $this->regularUser = User::factory()->create(); + $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']); +}); + +test('scheduled jobs page requires instance admin access', function () { + $this->actingAs($this->regularUser); + session(['currentTeam' => $this->regularTeam]); + + $response = $this->get(route('settings.scheduled-jobs')); + $response->assertRedirect(route('dashboard')); +}); + +test('scheduled jobs page is accessible by instance admin', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Scheduled Job Issues'); +}); + +test('scheduled jobs page shows failed backup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection timeout', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Backup'); +}); + +test('scheduled jobs page shows failed cleanup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create([ + 'team_id' => $this->rootTeam->id, + ]); + + DockerCleanupExecution::create([ + 'server_id' => $server->id, + 'status' => 'failed', + 'message' => 'Cleanup failed: disk full', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Cleanup'); +}); + +test('filter by type works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterType', 'backup') + ->assertStatus(200) + ->set('filterType', 'cleanup') + ->assertStatus(200) + ->set('filterType', 'task') + ->assertStatus(200); +}); + +test('only failed executions are shown', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection refused', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertSee('Backup failed: connection refused') + ->assertDontSee('Backup completed successfully'); +}); + +test('filter by date range works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterDate', 'last_7d') + ->assertStatus(200) + ->set('filterDate', 'last_30d') + ->assertStatus(200) + ->set('filterDate', 'all') + ->assertStatus(200); +}); + +test('scheduler log parser returns empty collection when no logs exist', function () { + $parser = new SchedulerLogParser; + + $skips = $parser->getRecentSkips(); + expect($skips)->toBeEmpty(); + + $runs = $parser->getRecentRuns(); + expect($runs)->toBeEmpty(); +})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests'); + +test('scheduler log parser parses skip entries correctly', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}'; + file_put_contents($logPath, $logLine."\n"); + + $parser = new SchedulerLogParser; + $skips = $parser->getRecentSkips(); + + expect($skips)->toHaveCount(1); + expect($skips->first()['type'])->toBe('backup'); + expect($skips->first()['reason'])->toBe('server_not_functional'); + expect($skips->first()['team_id'])->toBe(5); + + // Cleanup + @unlink($logPath); +}); + +test('scheduler log parser filters by team id', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + + $allSkips = $parser->getRecentSkips(100); + expect($allSkips)->toHaveCount(2); + + $team1Skips = $parser->getRecentSkips(100, 1); + expect($team1Skips)->toHaveCount(1); + expect($team1Skips->first()['team_id'])->toBe(1); + + // Cleanup + @unlink($logPath); +});