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
+
+```
+
+| 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
+
+```
+
+**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
zcmdpd^;Znf{1jNq|*J+-Abo)cgGNd(kk5`t#nEbJxC2DNDe(9-7)p`^Zps{
zS~u1`Kb^DIUiX}P_P&Ywx@shZw1fZvfJ8%G*$@E0i2mQi$Ngui0JnqwZ3I5*X8r&G
z5#|3T1|YxiWiYj`*5rCu;s9B_>py=GU>kl{})44|6mXH7M`IbnW^;H
zJ!!W^Kk?&xA#V6@3S30PcccFoqm4xZ{X|(roYwNpn(@Zj56|1Kt|YHU8jv^k+4p7W
zeE`GGwYW16JzuI)Xnj^3KqAxIg=e50m|Db>R0Rm2O@nn%;L3<~P`P+3tOH7bV6b12
zijO(-NKj9}dbM05HQ`#g*YR9^>i?XO%v{!abSsnwQjT|mq)SUMjXk8ZB~KtArd@xe
zC-{#y{Mz3ME_)ntwk!K2iwsHJMT$2R+#j?%!TIYMA0o@0R|y*)dM{g*N$y`lKqd!;w0t)y;1zrXM!WOe5nIIM4%Nc?c^
z7`2-iiG-W?>efKa!LGM5sXOW(=_EQQLR!*wn2>;g@D7$PV>UQO`f5grCw
zcF=|!Fo@6pCD@_X#rlEFuhbyNPA@{Q%!^P;1JD>K+~U*xbTLiX9Nr~T0>x-0cpq8a
z;ThGq4`;NBp#zjGOErM(QzP&0iI_~`Xx|?h#v4|jY>9B5;}pfn@zY=d*PI}K);QBP
zp8o4F9&|lE{0Bh`8&ZbzKr7-1ZE5~f)R=)NhL8ASz;YyYnwI;f%N`$i87RJm8tJ+X
zd+^+f(%tFAx{o^^`?w6kTkEQhDzSh718@*{YiFm_nRv&4{K6%}SX+C*Cv&MxZ)ZtUKx4+$@2(@i)qGs(0#3ea
z(Fe0FM>$p7Jb7qT1Gi<{OSZfeCiYGi(U@-DaK7*x&HJ6}rF8{Ln2Sso0QEhf~T@k1e!V2HjbdEjfgzP3}DgWmNJ
zkX4D|)}lb@&f_1{ueEQa!8VzXi;v&I8_d<(Au?gj?JDB0SMTwb&)ew3n#Ep+9bti(
zCttfZ9P`B;M{=(r^W{GNgSEG@WI*GxI*+sctP}=)rGYi9$*Pkn81Lj#({<`WR|kCb)@&st_>ue`2~rCf?V<8O;P9R#|R2I96L+LOVK&C=Bpjd6in;fwIbh?qUer`h+Ikpz*K
zf9sHo?M4{qGJO~*Lmd3iV@)Da6ado=$(Z$R*+5dflm#Kd^O4Jy_6`bP7l0i*+ExZi{WbIKoU;D)h4_y0p
zweBT(eUey+pLXG=1FrnxoDj7)dfw*%a?Sc9EH`^tac#I1v!HeyT>dlV{a;=8r*=r0
zCxjt~G9ps`PSNR!7F#jKz*xKq^FaVM_AHW|@Z(i`P8=X4D-$y!?n5b_`tnUpVLyMn
z20=IM$Jh*$+H>9I6RS0`dzR6)9&8L_I3C^rVL}<~r{GHv_zCH-98Po%PVqtFJAWL=pV~MKDSxEy
zIe+1Lxhu4;eg?e6R$@UG=_Gr);y4ExH!Zg6jEp!u%cFkrIAs%tTDRxnUedefHHCML
z`b;$XkQztBb^mJ&{P6=MJ@0#bHcgMiD_-$Hx-?i8L9(w-y8q7LRfCL3B;DTZ4S|KS
z@bcN|g*}1VS+h5GI@(1-aKX%7y1@RLe*27-zV(Nymgjw9phdM?@oP_{A0k4!W8!}7kc4z?+Hg?NfYe%8wQYX(GA+4P+A``yAJC)vRbnomgH
zmjxXtr2FqP;ZkEvF-~TQrsNm8?vYQytXt2T3J^-mQCh#7ev@Si|g2pC_WY2nL{ku)3HHTiA?>s=T}_~O!->EV7FrLQo+>zX2p-ZQP7)0;&PMm`T{X@-tE1#$?AS_xXQUr
zr!4a46wD+VghokJ#r0%y3I6JZMH#H2sF@!2klt&c8ztUiQ1~bW=+1QwH=#o50$T>1
zaS>_J)_q@$$@Iksg>rb}*22iVVLeNYBDf+-7NKeGb3awB!5_e%iHp;${(?w=RLgD0
zU%YpP|I?Q5>90*cuSQ&%=OkY{1Ro6oFZw_pJsaI$TPS82yz6)o6#%lGD=jB@Z76+z@{R@s`-}Dg
zBfWn^aE>mSN9-+c1cXe52hOn;chN+Q!|6b}QkFd39RL!Z8f5_S_~@&JQ2X{mPo6#0M{$wFHZi
zVBpyf7Q{T$pycpQXjs>TrHSzI$e-LajjevvmFo>sA*=t}M@%N{!Lt9w`~4=9XmtDd
z>!TCk;b2w=ATjm}?Ab!K`&&~zFzHiX!sxO4;4;Ou)a4_11dO{(IAlvAFrwcGfG$Pkecj2u
z$&}CrMo`It0#5T$Mb!-qktgVObTMq|%>}t70XRa{qs-%qchE#gPZ}vK@n1ofPZOav
zWPc0mK`e)Hl+v={C_OKqg*BYrX9({u0A|ZP-pqa>9ZbA+FAARn`_`XA&W&p_u^Xlu(sR70~@I9~_y
zXro*{AlVvTwqbM~Trao8G_PO
zW6x%YNEp@=co{Uw`v>It`(*#xcm34+StLZbq;I!BLlAvPlP51ShKM}Y2%c@$G*2Lt
z40=?anWMzVQwaWG-mnFfGBU`<!;31kHVg*BY&0>_S|vZKUjDE)ZgtqpK4yfW5-B<
zOC1XLa4`HlTFg?f_Yrs6ezPCr3;)p?WEz36Ii$oKpvsv
zWlgt_>9A%nH3}1|kyh7~vzedqI7FdNEBn)AXu4oh<&v
zcU=E(5%0RGu#SB9{bAbPVOZGNSO-<+5av+YuX0|PQ|g~PF<`_pkO=2Sg&z0(y@E8_
zqW5-v{zSQLcIy?V-C83DN&*v^%|qlFs^k6j|hcYbOXfkpqP0b)ngKXHevi;nfSZ
z9F1;$oLdP_R=)QZ1Lv60H=n+KDw$n3idJ_;cAvJDW8HqPmsdDub1e;us!nj>&$RuF
zxfj%MzGeq^!_(ErE(~r4gj)&sF83Nzg
z-_KFc6eD(kys+YOp^IP6O@vEm+02`eCHB^Z%7cPM?|;uR2TA;BZ$HVR-zf4u9j%{!sm*=~}$&6B1+EulIbx9~w~}#E7>AP2ROWp+yniwR}Tm97B7S+>wKV
za!;EDuq?E#_`aHnPfwh=l!9l4u|VZM*~TnCU6LAMDZ%Fc%Y+~SotlT3(^bQv|E>WD
zT~xopn|X|03!Kv@b?cs)*_fb!5fGKWf!JWOC#~M?0L&b%>-8T!JelqJKK`qq>a*i0
z9|{aRG{2?Erp@~`^rPRsWAW%j}qd)FkrI2ff7KN>y1&?n{Y};8_48|tfN6h$$@hWONyFN8xzNfIzVJ~p`A=xur%2@gf
zhx(PlG$&O~-S^+8)H%q9W2R6@u+3Ber6DDr`wU>sL7Gctj~QU??l@i+fi)5I)Q22p
zW%j(7r8Na#$8(05Ofn~vO;odUd9K)K4R5vjpGRpX_#$4kdJpJbh|hM)7nI(zehcvm
z?;&2kSq7~!*`SR*p#xA6q;NP*j!D2{v9o+I40tgLyrc&1B&Q?pJAeH~JN3OBfJOtk
z_MS!`Bk{3dUMJpopZSSc(qT{MpRq4qj09Ge4Bxq~9o#+qr;1t3$z5FKxf8AtXRG0~
zRLw_0@}J2dvb$+^cIMF>qfV43++x4WLwqmult(4&l&g^#!~Q36<}Daov|U@9k(Y($
zn%T)r&|}`}fssC0jq-z%xNt7g`(roBWAnz`6&ZS65cwDMWMhqNXF~6yPcrUpK2PZX
zMf%FlKe=GDEE3;%ejc*hhA(SM_Vz-RB^l;J!$3yzWWQPWjQNhs-7UeF%gY0|FHuxC
zvL6;E9>0Z2XiPptOeTaLdC_#r_7~*_O36X{>9z_eP5KX)gFZHRq8TP(XFt8sqw=9a
zd_cdD!>Xz=rqzfD3hoT>GUK*Uz;x+BO1CmQc~LoSEe-ioE|}d|sYjRSTTLNA;w=68
zP0IqCD<@yZN?RDcAWq7B|H4Wy6*IHHSZl2BdDiC2tQ5fRv@xnwK)Q{x?XggM?4$0-
z$Ej}4_;Qu=+3y6-Ee_)+*p++0
zhgs&qXS_zPH6)6v4{iIbH*8V?fJo4CRjVdi5VY8RQ+Qo=n|>6fb1V)Ma1Qe8XxPtp
zc5`$x6!VlfVrm=!9w$4t`{Fi81`3L+ovjs{b|JH_LS22CVj;xBTc8#c3R;Yq2#1!M
zpvS$&L4xzFeh%YHdD67DZdg%nQ>?O_B~3H_V_xj12GR0HxdHjkbPIZe2CIGqR4dU^
zFu;RjQIeT${U@vnNb#)^g|vpo
z@9TrW!+HU?Gs@Llmm=M-LJ3>0I{WrHPb1
z;iYBMLD^S~4Yla|5OU1MAwP0}joOjqgVwG|NtLigwr+>p6#{(93g$1Q+;7lpY$F|#
zu-dixg8p)^tXr&Cx8NF#@^csOS6p!V>
z$O*CysYb-;JJ+bjShsDDwK=B++F4>|i|B~8YL&GVt7npQ(NwXt<{wKpSY1_@Z}h-O
zEcMXzLMv&a097pY$^*V$8|G4iQrG+*EH_KFg|EyKM8$!e8#~+Zqo(40@t>rJYbkpM
z_r;v_2_huLU({9`6x^L(Y|B0Ewc)NK_5M;?B5HkF<}Ws==XPYQdcqZS<>C#ih
z?v^JvycmR9^T*m-c0|63O?{yd$%sAhqxoMYu>8(#J8R)rJ3fM1xg)uZN$u|msRvfniHxeHQE>z+~dLL>=h#5=RzH80(H;vhARrQkyo;(z=EpFD=e{TKsY
zM?4A^8$>p`-`+8~COyZ_-`X5ptu3g!O%D3#ZOKxM&Qxtwv1^K%Z&7cOA$gK97RwJM
z#KuqiBgz|Po!z4BL0wz*cG~%O?f3P+3CL$AaZSd*zIxipbZg+vU`XIhbg#&1N$B%B
z2lfbk`L@J|rQV78T+Z9
zGHH=yjd9?qBQoWg;NUZBne)&)`}&pY9fp_S63R|vu)vV`Eu%z$O>bM2z@;++6%67i
z?BeSDl}5S&ThhZq!ImLV-&0B`8U(u2LvPk!?}LIx#=?JJ38nmZM)Qi5KB4kGrz~s0
z1FrewrRq`L_e=IH1^y^vjYWDwkgJj$G~L4VCHssP?)w)EbDfTBPoVr&fyc{uJT(C)
zPy;kPad)@-t*7%m>(~DzFdoY)1+i5V^{35FbO7B2xM8yKv1;we#j)LWCtUnWroZ;f
z=HK2-ihO67mliZ1m=-FoOB(u)&lJ&HRHUver8UT60aBTH7cI0&a`gCByD61Thn>j?
z7!kaHFhP$pJ?#N62W?P$$ju!2XyLR=32dOH6WzmrXetef4|n5;U&w2a?$Hl#jmc?m
ztWP|7ux7PeWyx&1hNi5tLTY=3dOQ!C$!RvvP$jG=EQGTh)9%F1z0O+aa~Wc2n;+c+
zaxH%h=!isFG@@1`y_XviQ@St^24x77Q-60pB)Ey2-I}^
zyB>5vloZf|SqzXcw*gweffr=g>#T%bs;O*%PWE`_F9SlmvKp-0&Xa7
z8n^BXmO$`q^ku{OraT-B8UIyc3fxrqOnR|A|8R;86{jF3nvHZW%mGt4YclJ9YDysi
zThq(ac?cOZGUpPkN=aaSfY)l!Dd2nXlyS=7dzCXt|D`Q}u+;8*-FY7Hs5{*gOSaxJ
zX`4}G^2V)-+01d$@tkXvsUSw%c~ZolDa&}hEyw|uesJnw{h%b}NQvMw{xlex!rtP4
zKfTyufA`9Q@mE^C5sR(BNw9e24IAt%d@5%1KhQMe*{(y+>Az|uai1CBWs3hKV&5qL
zt=j9*HLnZWj!J$Vle}FhzVqPyd@PhY8eJT&Z2c7BA?%4hVR%D);>ht244^P@_qo{@
zCf=NmtIYeLAd1U=#(?npVcPq#t4HJJ>c1^=@~m%
zq&tP?>s;vuFZl9b44T8V{7>W%Ip~An$i;N&u}kpdYi|)J(%7vm6H?65tOZ^_PfpNQ
z_<^Bc0a-=SO;Ef`E=hXFIW%Mv&p&?D{RfH<@GsYT4
z*O!8#KvZl`Y7J3{zTok!60>Gh-%(Q*rUNDBzC=7nDG#gWFr!j;6_c8g#G&zVu#Wgc
zY74%P-b!qTO^$g0mV=Hc8n+qA&=#qdd~unq`?eaRUs|v|I{WhcQ=McH-S{=-oSW;-
zd|+4X!BM1t`No3p3Ao~RE(Vp^0sS!RA@Ty&T^S#(Ue(L_tzD|}?OTI|rPh+~0f}|4
ztm;qdSF@@Ru*ZAESsQX*G;GoS5~Q`Qe
z>xSqQOGWqW&lf?%PixWFa}(DkxPL2xp8I==(}q1@)_Kf}MPm!^j5zoKKqu<}UT)a)
z&@y8EwnxZyMPi|yiwQ0|p;bt9S%#W%H*C*2)z_I!v4q!Q^
zdYvOdh&kjXW0=hL@SI}^QV%LUcUR=Z?T%@*ua4_Te(Cm`7LaEiHP456+H*jR4Ss`-
zSr#2(Yty9fHJ#TnT{9=yg-_;By#2(JvcpvQ`3l~pZ_!)ithzRG87c6edF#SaCLCRf{7-nCb606Pbtpl99_jk4=}dO
zwHOM#VrU}cVn-a?7Sy8RwYVm)wn4xfdH5q4LJ|Ajit$XWeAVD5@q%DD>w@L+Zi?Xow@(MEeMj=K6>iDFfhrguGa8%$rS;junc4)*X6Rys9L!{
zZBJ)h$xRYT9F+d1A070_NUJ7L5n_2FL9SPov9xkd%`8|%L9tzLXJ89n44{CClAp`5
z-C0YnXbY8)QEd=*=UsE{Y?EJT+U*=UN3S-b~y~w0MG4Ci-7cV*q289Y?jt^FYa%W@=>0JNUY#G
z-5nyZX`MBj>CO%No10Fo2O!<#=Vi|>xi9b1ID#}>1g+ET4RatnDk0UbXjn6xzy)=1
z*mopJbz6A7Tpmvd!yj~odzq8REmHH+M+73i<
zopn7;fsOSeylcZs!{2N^15PhUgojNRZi}qrNHs?qM>%3(!(r@6Gpp;Y3Q23fqu*z^
zigi;P^OoVqQVHUPXGTS-Ngi&`kAG9U)DRgl^F8YWDO0>jgsYFiA!dT!
ziFB$(t6sp}s$XnUV+}pgo2CdSsmK|?5k(J`B1?|!0oK;)Z%cKV&XL>kQJ5vR_fCx#
zqO4#=G{-D@$^^hPrW{9kg0$=>QpTPRb9`WYG5*2!o61i7+j-Ir$B)Ov2!?GbPTM-r
zA78aqCo#z1SF8n}c&6ne@7>Cxo+{Pkk)PdB)H@>8AAGvw^|`F#LovUY>i(mnEv>7M
zF&4EqH;*LJ!RWrG)br{ky$SGPF_(bm*tNyuP5FB*p}!V5$0v;c=m^8Y;78xNb5xXp
zh0A_N)>){A+fsuWpdxhU=$`gg8IvbD!}m1#w^ujqNQ35AtkVNls|rU33#q_wswXAdt6rlxDD+F~c+
z=XY;vc)q{2Dw;4GBkn1k>8wf&efOXB;Q-MQ2gM^xA}biBNcsNj&%{UTGxvc=juA(f
zbIo@lyB>jlcm-T_n3R|rW%XGW*&NG!#nYXG3EJP)@?WHVB-(uSYOQFcOJDW?$44`J
z9`j`@Ysks0l~{6SLU|W(=fKX9o6TaOgfjFEyDPW12)r*tm3o^Yr0?J(YDf_H1a3SF
zlIIJ9+Vj(V6Q}TV@SN8Jhr>Qh#yMWpb2rivl~y;LhR)nB+TO@xp?Ci{(1Kz>82ucc
zJ=|0~NBdt&!?lmmgTcQ-wlzNl<=>%YgC`~62k(Y$$immw*eVvjw$N1
zp{R{h{y<+zFFbg6>D;I_pswYkASm7+OM)Py&x(>pJnjoOvXsw6kr<>o@S1G1)dPLF
zW$XR`ShC?6QK|n(2gfR3rWL8Kn5p@5RJZ^D$?*X|>swLf?X$HiEb0cZE5wowB$%n@
z584N-${ND%!uNuy3swtA=1b(j{%
zqsQJ$cwDNXMb1;ZR#nU8Y{pZXPMbAN@3U84_-+H+zPIu5b_BMKYV*tTM>n)NxQ_%<
z%XQC773eHb-^=d>Bc`nif2gh2B_GN~HCW*KGR#Qb%@b>XITfA5Ig{@xjCg{exAlOZ
zFt=jeU+!rBKH5rp;i~*4fE&7ZCFQnP-vKngzR2q%8%?t8D!udN@Vb6Z8oSa9vwu%r
z)JuHcX4V_zkq+zBJJ{+RXVhBuSx7Qay)QJ|384hwK8ZJ$oQGZ7^zWYLfNsRo-U6%p
zil;vaRJpvE`RkcxgE;Gt4$)=9DX;^`B?^uWpdDNCSy~tkSd>M4$0Kv#Ns;%B3sY*1&wWssP-A;zkU0eY=bhxW
zn*3EKH1wCQeDN2WdzQBks#=wdbas3zG23#Z3B_V2Wf1;rD~(;f!f-r-S&Y
zZN%%*1QqUQt)br7h-&AH&AY01yTI=?j6_GhqY~)bb6y*>$Eby;5+pTxbDAp$-8j6w
z$w2qf6L9AOl6|Hv6-Nd5OxeGa_iR571L}M~Z+AQuYrKj9kX3pQwiSvLtMND>yvR4|
zBsEh|PRIJCout+yp7T7lt%l|h-E+%MgmifN@E^kLV9#zIGe8=we|TA-xb)(50zTkR;dtAjO_C;t@dW=P!N4HQuX#v
zhx>6ZdedER(-%sVj*KsNL^BCvx4yrAq}$FPtDWQ-oQ;OwlT2TmN<6-r`4U9(ONcU_
zG|w(sGJ_gUr>^E%wa6-jv$r^3%TH~mSLzh4mG@Y=5w=LiN8*C&xw{G>!^bAbk~;vf
z+siVt3hq*h4us+Uy^`#1F^^X2J8?|m|jCcI?8q*0=)WLS$qB2JMtuL@W)59q+
zx3cVL#Bst)6``N#97eEwb!v*|nU(VN_MeDHlLvK44{n#mB9egS3~dgNH*NMC);
zv*jZXm5b9|py=@0IvLYLO)+)fG|D~T$@_yCCR8#WB6VGF_?EXM9%QZAE#49kraZMH3&b1j!RbGZf7(mvc!Qsh
zK6^oPjzq|#`#LRq+%h4jN@FI~;kZQugu>QFllRUo{;w}~LZ%PNXO$Qm%afuR-|>LT
zItYw4Rr<{c8aNt6#wg@4vUEx%MNcYHX^VD0&Qy77x!$pSs`~qG<&$+
za}DP{WQei(&&7ADYYw4s#ie02Cs~Spr1fdMI#I
zwu%8FtV`6XP0Rh?3%QsO3-$B$!9|EHMP-uboHEgYsS9qanyw{zX%OlIo3n(2Sd>Yba7-lkAdxo
zII4?O!A414?U{#IgB)Qb+@y9~u3YNC6v>251l
z>PHvXlZzIk35TQz5Gf~;u?OBWo51^4^d353^4IV5hO~{fILJpQdBwH#IZ*QKbX7BK
zcxR!fl;^RMRN)?w`s|pZwylruD}}>c7?01V>ag)&m-G=6#rWgDe8>KY
z!p{@hbG9nvtO)3imQu`-bAzdN`O{CAz^|FoC9(R8qlKhu=;fMc{!8`zr@Au@)tSa(
zo*ov0EPmn)K?i!|X8S6zbA^QB>K%CZABL_jGW7ADULWpf4cmyKEzYwqm%xs+5AwmI
z)XvW)p{*7?#W_sr5>d&}PAdF;0ldh6$se>qh`@bXF3pUBCfoGxqH@r`A&vH1cpCTE
z{g^i@YbDsUBT2`#=SKKbW5-&!u&u%2U=0oCx9!_=MWK@bPt_b{?SA(FjHMgO=ya98
zkMo-`mkX@|*biI22`U=zFGZHs80w5x3O|v~f2l}V4&GzgtNk)CSRACzA95^`>Bp#W
zwZJdsMDV0+mwnh`V_S>A(K@lx0k5pA?`XZlsS}WN)!3p^W@MFVbngoB!;&W=yOktr
z;=a4I^eN3K{NgklvQ|^_{iyoZf`40ijLo0$121fqPc(?eFyK)zUgX@<8=$Yxo`*s5@=a`#
zT@eLGjP25pJ-4c}hl8P5MJ%_4zgXSQ+;k${?W!ec&Olf`KJMHL
zZ-!?Qr80Hgo)G?-`p1G;9vG~KMg$HAKlJ-M>@~S-LKQhZWQ(kTBmZ*
zc&nipcb|g!fSl|1?y|(VAl!Q
zxZauAyqJ)+&<8UE1=AVdnZ<`O_CCrZ(#2gf^*HPyqXJkt+X17jour
zkjSqbq6)oODzESr+%VyN>^i$OQ*ER&{G9Kn-_70SnGVikU1aosIe4fT`P_8zIqyxm
zTI{zk%7ZvR9B5|qgU*+2uDtKwl2w%K!0?mYn5MTDr+*cS+VLwE`eD+n1(O+Y6FCOy
z9g#2mz%yK^Bot5xUP<8Cq**CwB8i5OWC$m@%!F0TRH`QU>{!xhXNkRkv-mddE>CH~
zF6EpL6=wGy$6bip;O9k_{JyO-Amp~WOqW4WM9j%J1xGsaeO9tWSC*j
z{`pbjnnz5w;8gyi<6APWK&xR%(C%b90xS;UMT2LX>@!IK$gv~oq04OZ-lbri*OPS_
zzQuq=HFLtFnyj!c326J}_T_D_f9-CR^lmE+l+hV;$KiOrcQE|VrceVFlzKW|yNdl(
zM9=+v@dl$Xr0#9Var5D~=sTX=Kl{cbhBH2e5{AiD86iA6rlrW%8N{h=-&^y)K3RK8
zrrN+I+1jF-ZVNhr)&iA`<4aAKV>ORtv*=Ilg43)%j<~MH>U8mJFFhkNpA?wUuS|!!
z4Ya>f&UFmLQrJ2dXBfXvXz0#-DH_S#mYUf;ab70!AMMTnB|Qj?sx-r;0LeDfv*m{!
z*6i!865-gG)BUhTTtKC}(Zl^W71%g?nha1y^8`GtEpa33cFZl+#Pu4I12-$a&DEfiN>>EDFyl|2k&{19}tDh_gG;M9B1ut|mFFN&-5{dwEqceCjWe#K|(
zzU8!)7=zY1zGn(KC_y~7`lDffK>`E)hT<7S`UMDfIS2AlN*dnX?eQHKAAnZJ1b%tEf%rT5zZZ`^2AYgki_uc0VOfFFI}B$Sp6|qmFd(O-Sm@k*14+YU
zY@a;!S)0dSrcpm4Mjq7IGbfxBbD8WKmmdA_r7w;iwb`%(9{8O~Fz7KcJtz>&mlZvC
zY1|K)Lm&czevlQ0C_ITdS^};DX9O^T+Rt|p80n2v4#cC#JHuxRpV;*|x8j8IOg82b
z7wCOx7a9{>2HEKHL8jQrU?Sh*#asktnsDF*l;=S06Rp-dpZU>e^m=5(^sC&19I1zkce4e0OAJ*jWm_oc8X27oa1OvPU3HOK;;5%Lau;
z-wN|S!1C*Ke&Cf6FGw!-<%#)^AgU7^D@^Ll?)X6ji&5@sz`osexKz
zR632N2))N{Grc7cQds9vD|7^Xl8KP(RtJeXA%rML?=mfeP*aHLRcS|
zU*d=?%nx%7%BQ7=`^ZJB_c!MLYr7oaa&y!DI@Q2CCHIfENAycuD{Wj6C~)rE&V;NT
z3Uwzx{WTMXPJWC!Fp<0V@0c8WP1Vzgi}Gv2?LYf=_3D*){%`mFo2kvSLq`u#462-L
zB7%^oFt2}Q2IQsMsPp%!7vJQF_2K?PS9-?zI?D0)ox)>E)^QHC^5)f*QEKv`
z(FzW&qers1i#p$vx_ECBZG966xcGW!!0xOS2G;8uWEJqVj;3;3jR~|@B8qwX=ts>h
zK7f4H;=oA>aC`w<
zxR>iIK`s7^H^~B0I#@VWtog2adCkK0pI-|H*-*E|dCJYVaI%VG*K}&w^nDcjM>$
zIHip&oeK@oj3CYWkff3%r+5cB2`&xwljq04R~z+~dK*DpGB7TYh(xvMGN0tRy_c7d
zIz2r6h+gdqw~tS;uM>{$oW2A$VfLf0zEc8Lx1EcVh(Dj^_Yq%45JFtP$mv;rSOR*r
zV=h6eL4;86){qkH2l{zuJ{vyju}oDt%JD*gd?|Us`e}47s%m=Xp}M{LiwxC5H($
z4(&UC4M&m_r^ungCGle#>w8~e4~VTg6CqZdoh2h8-Vp8-?
zkrHI{A1hU`1l3a`WpYCaHZTL>VJRy25uwP)>+W+0T&UiKjr5ar&DXSCO!MgT0UelE
z%HAT%Um6|sGFE_@wGtTO_c+VA==DIeYf!5iN-q~JH5Qs+*Ab)3zw&@LPL*1Z@T^De{?YY<=ue~zxrHQSrGau=(d-1Rjp0aG}M&;Iep=6
zGQm4GB<|K@y<$%b_k5QQ;TVqS=5O^g8~0at%{xi7$M56Se1BBXS5nDvO<
zv&D%x-G8@(Rg?3J+EcLj5(Pd<20vi^Yee|pHgE|f4Bum50kw{OW{L)OMIOQ#ne^p4
z<)|Xi8=W6n}y2afA6{Q{wvmGNMO)B{>zYfZje)>^~BTm5WE!nt-cG0YdeZ$pM9Vf=rhI^Hwyq8j6DgA!^T1s_(U=M$K
zcc{Z1JjhzwqDH_dt)m=#@o605t?WVDTYi_bAFJs{-MHTZ~^L0-49
zgJn|k0&vEoh*l5-Q{2q_`DA}&tnk)wOoJ9z8>7#U#
z^DHrp*lny;_ve8%qJOA%OO4Uh{Mp1lP0VU<_LbaRIwu0xj>#<@GlLaDE=K!Iqco;P
z)}ny$EoEO?!H-0{o##4t?G;Ifl%H0|Fv1f*F=;bq5M!j;%fj^cH`U$7abBG@TL-se
zAB<5P%0-8r*ZuoM)_(P!0>E$~ImauB-Zk&tukA2y%;FMwu|FKYdftWpF)MfdWGVMz
z4b7l^WD9Ogv))DN;ID(`Q`W$8Q!e>|A)(@se3ryTK%(mZD2YWlfm0=G|urt_MgBnYHj
zc4`(Mwa5W7_!GCU3mDIr-+k{O4=N^ozeiGVCRI7ThvUp&lW|I$e}Fk4eWo>#&`J%S
z`jY%-ma~Aoo5G1KV~IqgSpNC03a^O&4zi70AK+W}p=-dTv^4a9FL)LkNEXwqBx}(k
zc`mh>LWLQPiYxWWO%V`vkpj1#yR0Q}2`CsXld=rl*M4pN!1I;``yoncS^tan`D%2O
zLQYv+ZYFtIZW}q@oAa*~(YeuSu`Ka`P1bbDyc~g&JbA=k3-g#q;hx=CMNyq>x(`!;Wue2S5*CR>lM=a6o{D+q-JX}*!RGPl~
zuqF`1N{8o1jsLoK%I2st2bQw<4E)uR?pZ)-d{p06(hERjwl^Y;kjdQ@Y0RinCqFE^
zAMqh{m9yaAAJ^2o*Y(hj*Q;QAgd-8^%JECjd8Y6FUD7GgX#;g&u?!Vdu7IqF*L^}y
zRR;GT#Y1F0+8{IGW>b3`Jp6!Y`B_$KU%de+#;hisjNJKWO$qf>s)#uU$Q{#$RQK8U
zo=}paKo86682)UjDRtI0yG|Q*dwg8)SU#8B`GMAkR+pSK%6x;s#s;1($T!;MGNuS^}|1DNL_1Buh&Q%CQlXRWY*+30j6!1GTY%I){v&;9MLVUQLZ#M&d_+&)^mN`3+Y!pD*5V?)1lPh
z$95heS|C(G9-8-872knrFId}Hn=AgsZ0E0p%2CR39c!tkUF#0C^3aMFFH0e|@0sX2
zR##jIP#%?!{uy94{5oQlWeTkBf2-fX+xW++%b=J{^O|Ti+o#RXV62?ladl4s{y3s?
z=~0|=?W`z{3F7eWq5QS(HT;L;w6({RfdWz=$y8h5S>g(Z$TF>>S8mUXf1t05yrZ_0
z$(U$hjg0YFO=@rT4F97jiDPf1jZt-n1Hqg}UysVm?if<#MFE8BD
zvSWF<^{FP&<%$2{o&L{E=IH2B#MPg$YppcT3MbM!j+%;6IUmW9=sR_0?k``n@fzn8
z|1-L%a2$XQU$)e{CnQbq@v=!h`#KcU1Hmt8N|ibrz2|6Tiq=-n(CqHCNoqZBMt-Bx
z{ri6aU_hV0+Wi8H4z{s7WKCIC1`-~@eR@2zubo}B&RpMB3gkNL*qN+Op^$&|*u9SX}UP5^Yuhjfoi
z_8EMCU%t`5Bd5>$zbXFmgar6o`RsEt4EUeCB(rn=&iAj>&*dk#;P10~XM}p*cKhn^
zwFTVQ-_Naocdc6gS6&ePH(tE-&bM5B;%}~F;@-^WDFbO7L^94_wvpXxgE(4K`_(0R)r~gE&B|!pa!ZCImDdL^d?hbrceW3o~K!S(;^%=K%mq
z2<0uEI-2vo$E&)yhw{OQLhDJ|$5&{QHe;iFRhi`GQ)
z{&+!@n-FcakLe#;dI(Pm&Fq3)Lo^AZ$nIqA!`a
zgT0?tMWij@l7KS@E+Q|5OEGF@jLh>*nB?+|8s|ua3NGMiGh|!vr*WTL7$x7T$KP;i
zf_S-0L@`AerKaA!c(R+2fRVl-P`$!51!)>oYKg^(a8sB?lVPgbQb`DwhRk`*I6vAC
z3(zP-OkFYcdwJ>zWT%eBY9^l1+F&n@TR73l>v$c}Mhi69h2f<$)>$yI!%U!&o;X>p)JW+pH0r8wd4
z)@p2(_Ock&maj}9S)(t3NraTroaIZJhgN~W9}u*Vq-EfAPj?)j;8iSb+gv_=<)z&Z
z|FwV6(ncIhDwQ#7II|+Y?k#~=lm%cNi-12N*?U$`&*g7hS7-0Vixuukhp&~lV(CV5
z{se#H{2%+gY-aXaY6p+;Un1IPd4ufCBB0jq4qpl0|2tp)SI4Vvc+N&hM2<#$po*3S
zfDtw^g4JiaVChA1Rya^gHUC7A^sP-Xb%=pEPVF^tSutm9=aZC7lg5JUv^W%6Zur=i
zN}F!bmP;=#u&Tn3=gGrjJ0>$pYO|s3hjut=!Ox~G?k6XAVLsL}%ZE3Cl#|v!lCnj8
zm6i;()MaTJdtNi)eWs?15|h51H=e1%@_RK*Ol}K^MSD7-Lu*BS&G%d|UOcydSR2Fd
z85786CWe!+l-xYXgmX?=psjJw2>|3Wn0`$-ZlbN@UYRM=zn7LjX>QQ0LN}kMqi0}M
zdRqc4!@7zGh8(ONNXSpqn${jhVWL91QV95(Qn@4*G<@&75E_$OKLpi;(|P7(FGcb=
zkv;)zw(r8Ir?d>>eC3L%r`H_t>X=m4cb~m!`KvuDi2b!P2Tb-^mDhRswl_d={$!E2
zn*T%V-vtT2|KYY^yX|}8qUhVt%Vc6V1AeA+w*5}~+_Ta?kFS)@SBde@5YOqpfzc;;
zz42xlFTE`CJ>^)IvtMmlZ>_Gm1XCA#JLLnlMXXHX+>)Yghn
z(mbL~b;doN^2m_9(1ihU0L)S2GwNY|BCVKV0^2mR1|+J!g9)
zQ7#Lng7bmos0y|IypSE7{^Gq;n|E$Lwnca@tO-9E$?ud=Jvcw~fwJB)X
zkKjnjWu$}oax3L1K;09>?qIh8Wb0m98vl;{awdIK^9UN9Gi8%HqUH!_c2DIC^?uAQ
zptW<6iVEW2KKhe~hIamckN4*LH8p*=46ux(!UC8*I>-fTXdBCZ{AYjZ@7(*-rZn1`
z0^Jf|&!>QUJyS!kAOUcC$2PH>l??Y>%Q-CNXii_FeLDE}q;97tczIGc1OD6B@6X0^
zjXMB8WBmPZz3v-tlJQ+n{_{Vx+h6%<8{jp`OUu!V@4FG2{8+t#gR%cUh9ZeuF2tg-
zOk<3W3<1Rip`vY)9ulspJ0<-KBIV!=ItIvbsvUiF77e6qSlzsMz;UB-_z9|K$|k@6
ztdWi55Y~xxz2tP%4qkN3OFK=sph^T@ureihrZo0pHlv*sj_de=QA0~WVRWYho%ck2
zy&k@1Z_68mXZ8oNsGN-hd6Amf}vuHwb4o?bo`AFGGfWBk7w_`CKG@aF<9
zCk_0K_A&bB?O*)m-!qmgKkc;6q$3v;k5tr;(?T=UJf~XLr&@sO6Y6rCP82zI28iEi
z3FO6>9*`dN2Wi&sL>A5H1~?RR8O3AEp`b>bS_!_%9k5(RG5?VWF$D*eIs5TvxpOkeWG;C>
ztVUVKN^2jsH|Xl6QBD5L5>(29yhzJQ#Hc16w3l4sa`9Bh!+*OoeyKhzq}nE_H!Z$;
z2H75t;ds%)>)T7-$Fi56n`q)afrAT&a=fcaUJ=)I`U;sei*LuR_Hn)FKePHAo;zNs
zW{#mOyhL*XcV#|?gBEQ|nn!V+5u-g~8)k*B!g}%B+BS3iGQx?uhHU#;O{cL7CH_{P
zgX^#hI>qFT`q0n(i_iXqZMJ1$MG9l)Dae1l(m~(0hlbBmuQS79VEPN|w2yT9=c0Jb
zr&B*z$?N%3n!l~v`xxTm6LE3CirL(8`Q8P6+rf7O{E!Z
zO={U_RjD_w6SFR3e^@C7atjswDY?9$oT8CROD~+Lkrgv=HMc?Qn-N0Ey4QJ{E60mw
zq^OX-DT*koPBkv98;C7Uk<*zgo}@TP&?edEAZbWfX^ClQ%Xv+x=;8DjIcaKwrdbf|
zqA9*Z17|yh`C{)Y9L7HM*jBEcA
z^9W90f!gKFYV%2|QD43=jQ;=1*z8DJ%Hj
z^=TOf`lkIq`qZkSx0SrC7~fCuH_rbGIsZGu;p^#~SJ2rs!xwZH
z*Dl3`g^rD*c?un^aNY`VYyq@RNsB0*^Bs_?N=mPq)+f`$r3IKnSImQ1RHk`Cv>pbE8Q8$}z_5(ptlYeE=Ky2y5sc{*hg3meBMR|9jH_j<1y
ze!2xDNAbno?&zk<#r+dVg?3}6$;c&A!=Qx3wR=VIx*PP
zx^etYYVpHxA&XwEMQ${MIVaatzb5I3v0Ed}bCkp?F3!LJHTqbL#$YKlh_g
z{u@?P!{=2{=Sm9I|0Aytar>D0@sykFJqw>V`vzAsi{qgAY)h#M*0jmj#N@f$HqK&
zLZi-%o{|GM1X6^_3>Px<6rVpEf6q_HYLzx%=vN5r28X@zAerQk{7pfL|vEu$RK)bi5;p!2uo2TDh-Yx{WpW=&Az&u~#qzMtHFIfAt!
zXoUWPW-09A=IKuYO_M$Z(d*^{WS1@bEUlwl8=|o@coi8I)_JjsD
z8qyD;S{A5XAUlxGIZf_pT(l>ry*b@P!xAm@YUGIRwg$4+P=TI#r2SnJJ5xkO)a@Q8)~N
zQ+pN*WPp?Tsa3
ze|rdl?#Mcsl{<)np+y4;7K~B)H=47Uw2_;Td(YzW*Zaz{o#uflTud4
z1HvJN1r+b?^#iPBq@!V08$&__NE;2aFaQSDM4%LN0)>eZSW%Y2too>=P>@NXK#4Pb
zu{RoWp41JINx2^J4hz2_Rv@)^l@prn*GnqunDj>&yS`f?tZ;u4B^UMlhDhRh2
zruzfDPb{G3Y*1T7&>kv53nN`l^r!#QZ+pjo=IJ6trksL$8tU>oDc>?50N3-Sv}KBi
z9O%b&?4#&Hci$Z`{%^lc=6fEA9ag&bPw=;hANh{he``zZ_N5j4ZFaA1XQ(H^-(Yoj
zQ-J>+*FN*k-E#TQFpZ>THJfRVN0f~MY8L#3V|FA*E!6D2(~k(ySlm-LeZmi;O#Ww5VdDk|Nq|rZ3$Us^s1!CisXX-|e?ug3smzCR?~YE%J!4
z3BG%oMV^GuAR=L~`c#Zx<9=s8I~)$PekU}~L=XusK9oO3!6>3^CVUmCkqJ(&-Uw47`zcrM}bA!{jrn`yv~>d{30i>Vr7qCh-LzGs5i5&<>7FX#-ui@>Bs
z(Y}nFnI5v@X(l4VM}yZF<-3XRxc>s*0Q?`FF_Y*6iZ*e|aiPPsMF9Cd7h>*I0xKOf
zHPH-O^nSMXo~5n(rqKT91|k9a<$eKm)y|<*9Q9o`!a|B_@;~%=cA?C6+U~v}GJGBfrnBaQ>3^@s+&97kn9X_IA$S
zX8GPapS<(77e4o2S;SB1tqAKB#b=~#TqL=Cl0uLQ0sH|vt~N#g8L`~1JAGHPJVQ7Y
zf6*oqj$ZM>u}{Qwq2ZUNQ_;c62J)aDtdXxq`xp&@L28wz?F4u&3Es3cF!OjxIyRSq
z6lpdC?RkaJ&=fXwRsz&f7qrEKlQ_#av1TF|ej?n_9l=^aI3>>!2+gGn(5X#GC>2Tz
zk=*7E0=oWNFJ=YHGI=nsYdgH5XRL(x2fFKB3@m+u1%83>35}
zI9rpj_n_|)tT(K8kykc>%yvD}=P2QZfj4RbdU8$UwnnpA!}I1Omg
zjF5qr%A{puhcItM%19{D9y_lE4Jfv?jQ;9
z<#%er%KA$IU+mVFRw6($v@4*bB4P53c&4x@v_K_8Ru$*dc!HG?8ff6uEGs9SHV
zI4va{;us{2lVa8KIfP2FZ(F>Mu-zhp%Y`~YKM-k|u+SC&`hqq;fePE&EYflKJ48}A
zw|c@-@N*;1ADv~!8o^Ko?4BO#s1eiez#q?*LecJ-$M(4d>{MEGOIjPov&X#!*W7UP
z%WRTegfB{^wD@kZ)q)06+J3&z*|tjx3?fm^I37*oXA`B<=3l+hy22^U4;H{Twq(3Ew0>({=l+jF0DK=JexBm0Ugt&rE)u+*I~TN%
zt>bIS-Aw!c5*Bm0d{VLg#`)9fYvBLo_3!4T(LSrg*Brm&rik}9@PF$UANh}l?EmoG
z*q=1@`CU>lFk~ZWgctx+8x|z%nXn>)2#y>;K>>vQY}CvMDY%sQC
zQF&o3ugF>wqQ
zQuIuKrxYI?^%fHCem7cf6c`kW`(RR*JuIx?21AIjj#H&){`8h!THc_ok6eU;dZCby
z=UR|2KWVVk@I&~JQi^P@0PGV`NJy&Yf|&px5i}0`GhG-&U8$sOY!)_JHeR8;;wS2h
zcGo6YWg9*kT|FJmFx1+o4}k!3L@t>n;EwF0cCKCPhyo_I=@DMN-5nOfID&r;=dEw%
zC;q}e{p?RY#3#`0YJ;yA3*cKI<$}aU&q^PF6$$|UG0Ugz%N4y=$8TTF>#h^e*S|^e
zV6@NkmelR`IL24n5zY^)3nc~4T|5Z>p@SFC3`!k<;#~Uv{@!zaIu-hx*r$zW^
zMFL^Z90SQ5qO5~#Sk^`F!A?mkGlC)-NOMg=4emIOaa7qwV{o=E-KI%1F`Q1wKqJo#
z`~pgFn@}|IU3jcSetV?1U&s04wXzwrv3#UVEFA6**|_#>*Gz|JVByphN}9LsD|AU4
z?z1Rf?Z+#CO3Hy7&|S)NDUlPn4=9G?nsu(QQl78bHoSEI&1aO>zEo0NjvzEj
z(SX{XOYq$AYHZpeBEBM`NMfc39M3b>Z09v(A}R^a;W2Y^po~RNhlkp|$B2M{!LUz6
z_6$btn4E%Bm#HDN0qdZX*ORJ^*}_ECD|bdVkwFzAIudY|3&_~QG&$o}icH`9&hC)?
z8n3Q80kjFNDd=f=r)dH<^2W2WzQ;D2Xu
z{&uWI`*0aY1OJ}h!G?`#{!aba?-c{zaQTVIEH|Rsi@MXxf_G
zu?Q$5&8(a?Ax$yXX4W}1W5FaaklWPa1^1<3h-XQogCe4MU|asG34S`MbtYYcNp@1=
zL!0K9gHV%sph%jjq~iTdN$#H(OK9D+P$EOBOoTDDzDhbNC@iGlk4&B_xUN)o)k@R}
znYlFC=b2hP2Az}o%8_4wlcCLr626a^pqJmh-S=>;N1#Ax_|Eyuq&6Sch|_LyO*`kB
zBn3nYCw@;@4`EeJIA8o7B9wBQVmXwE^bzEQ!o;#N5G5-%2`1?%Xi!wg@NYwBC`*FF)>&iCKOTHw+Sbxv$f4hij
z{?D|0{x?DMx5Hn4IYNd_5WM+Ik9<&kRj&Yk>VP72^p^R$6%cg(#!TP@^!4{1U>pFk
z16~GQ6a&lyx07ZeC_#UPpFonD&ZwH%=$AdfQ!(3?pWslevoDR$m_<)X(_s_j3w6Ok
z`vBJ_pp-PrW(Ke#T8TaZ_$h*?4*?__hu}=`Bg&=#!lThg0Wv~UC@j+Q)@Z#CvKGw6
z1bQsfZPp+Ls4D#-@t4l?@MT9X3=J@im`RyTmB40N8i$qvG!O_f%YVQ+FUXAP?||Sd
zP>dA9Fxx%;mXo;^2osS)3+igmU4DY_8IbmKx+gWS%ok3YsHa|7HQ||ghxX_gNK>tJ
zdbg;eSpshJr+D@z0fcEr@^~qwr%Wdfxm2*WV2%lCxws6k$DvBy1lCYEe}$C_R}ck+
zw#cRoYZY@QzxzM=_a6VNe%<4C4_2M3$7_@l`lkB;PKSH?$Fax|G5n4Ec&=Db{
z#jOL>(Ml9Nv5rO)Mu_4GT!Cg*f~0Mi)S~G($kYalOs)yABsdGFKWxS_1xqAybVn|z
zeFAp@q%evd>KBEo%Z1dq;TM^wa0G+duCXBVoVNeqKxLR-Sr;k5CwtB&$|*x)Q@f9c
zDgh^gsoGF*3FH7?YPSy1A-khZ;Fv~S`!eBc5wveA3n5q+q}Pt;@MASYM9YDm=mdeF
zro{^dhrh2_*MSawKK8m_a}pFHfS6i2ECZod9}eUboJ#8?p3en|Mg)-NELv#)5%z?8
z@`Z7mw3k`!9GjbU&9a;Zg$A$a2PqKJEzu)^+!CnYlMdlo;2)a)%wuCx-IUCOv=e9s
z^QV61pMCCqzLFT;wXqe+XgLXZumHa9lJ8fmQIA3XS7A&k2l*gqwP}U_Z7Qv4qd3iqP6M%)2h*l!qF~Np}0O6ufaotlw
z!cPV@gWN~OK!gK3k82b0pry`G@u=z{l2sKgQ|*a%*NLd2>Y$oH-|riyL0VvVNNvim
za@j|(U{E+A6-&UP4gt<{CcPun&S&H}s*q4PW`VWpl0WE~M)U18Baejh3Kr0QJfE{;
z=`7xB3k^l+sGaZ8LjiQlzBNKN2CkJaT^r{m2=!9gntZKO65+L3v%Rkq^l82ERTAJk
zZ3KXC!w!F%LSi#ZIn{XnMx7x9rYHE@|J+ai|2}uuvBum0j)4dfeeKQ;{)P*HS=qCM
zLLNdn%5hj)9`0$IS0V5K9lnnsyXTb%_hiVYmD?Nde{_3=d-@FDiL}lyR9a_p{&fBt
z=YMtmyX#B;-3QmpfchCpkx=uGJ_`Vq(9D+ElDau~gUX=BjyV7oBI?^TBRsr0#!PKn
z8K(%Wm`YGit)1h@$KMoaxCHQ7)CPKUqeaq65ou#_0v~Y#&YuZ{(4K_`${4&~M!>kB
zV4j%Cq-U(_%y#Rea&a{lU=N8?Z7Vs6OQ6N$xtque8bW?U9@}ud
zVrx-1rzd-dkUBWqj4!O-h<{5^N4sKu;XEgXYK#r%`8DXFW)&zWkF{-6
z;$A>qPtKw}lm{+LOd#%2@udEma@i;M30}GY!w9BS5@z}ejAbEx6qmud~%og*H
zwrGyo@Et@S(i68b0@%HuO@K8VzGV$W`=87T5w$~@N`d@;`;(1uXj+PF8(2D47Ef5(
zzqI}pb~lzuz>DodfWs$TGl?k^Z!+KQCPWms%f;p;CgvaUT}ujqFe;ay`m?|HvGbfC
zkcPwhQjU+YNULtK?bTk6@OOYn9DRM8vma6#bNJ$G+dEr72C|9^RAxThG^v%lGu0-y1F)8+O3KmWvk
z83a#&d|#YBsP^%+fmIy
zK8V;&6`hjF!9uGIpj*(u2q*>WVTM!$*atr0dI}oFF0@cikHH04vPM4cI0*a0`13~J
z2f&lmmUzGzn80pjcx65f;#Wn`E5DDBG3I&h7!VXg@QsP6U;5>7Rr~8_vC_QgtSJpXTbp|l<}}(J=i!{KhI44E5}p*=iWrelGf--U+4H>^HAC~69vU_@7e^>!yAJmV5zw`ZX
zi1VE8v58(cN4&ph`CR{QdHP>`aDA8eCY2*&mcuoEd)*!UIY`pu8*$;32Mkm9f4&CjXxTAKWU%FOj|qz?hWlv
z$)Quz73t5O4mTGzUIADzlFt~Jp`?+-Gn=KQqLT5dptpR&SJpc)jo*cUjT1y_juAA+
z+3tl?C=``dN}*6KxmM&2h`fcM*>6
zpj-S|948so!!-q{ZO;hlbfsZV`c$f8NYE>sMeHJe4{aaE&3&lBevgwDRWxxdsUc>~
z^y8`Z2M1{xHtqX7k;eOnCE$)?S^$}O6OEmgLA>C3+9{mY!Q236&m1~Kx5!t|dNqs+vg`M##@9mmh6#Y~
zBr(tqIru-|*aL-=Qa0ezIovDlEqP<4*giO8Udm%fZ6QaT90dUCsDyndrG=7bO-AWr1H!MqoR
z0ag|{{1h#{M2<-&kn?LhSWMIb!UYmzE``fW#GM9R>>8717-yY}i!zrDs4d(l_6`6f
zRd-}Fg)S?T(kz}uBN9upZB(@>8zOD~L)0}Zftu5)xA2^-npb3`9i@~^6a-2{Dj^^#
zTxjZqRiQHl*FsucQ`lg3bUNPQ4-hziW7I4F^qf77wnP8nxv}izkWAeW7OiG3C5iCQ
zO#6WsG{F}-pu1pj=W)#qfP?u)6Rv7bLhD4Lbq>bEWcVjLaTmnW`~>ZN$n0;67J;7L
zm0JHMqDx_o*!P_@nJ)$KM7fq;B50wRN$c`=SZ_b|Gr#-E`#fRXg~+_cd_jzAe65sk
zhyW<-}i|6l8%?LsQaq@U%G@;4{t}B2jl%uO7lOn
z^8UM%*q6Nj8_MZ>@{(oaSwa71@cz*L&s_P!PX!8){$XsGP9#znU_3Q;6;Ap>GsR0s
ze7bk+Q_(+4L>aR}G2NPQuE{aN=9KNINhg(3+%Y0K={P*Os=hofc$#w@@{Eg5GL(ZUBJngel8I7u-GV{%tuqSQd7gKxsqTOm)t
zEczjMuP{p{XEGx;OQ_)|u(91oQvnIWvmDbLwDvKWq%EE={!37rQouH$z)tpaDUgql
z*hP{F(oNfHa{UvE0JO|qi_VIM0npE!%Ye2QDhUFAz`j3!e6dKNWzWc;iFJtqzOK>Z
zZvt>VkC6S%nh566Lv;-Ah0YkD?V+P_-JDgs$3sDY2*I7#u917AZ_hnzN&eh<&gc%hnJL4fd63KcIS#d|4#vK*(ze;43<{O*GBmU
z88gxOQ!?VJ7x?WswEw60`+P+HSj3MxexJS)+qtgq&B*g(@%~p3_IYLrr>`e>d!q~{
zc(YUwtA#MG*eu_do%atvz;sJt-2UZX`NL;UUiw*$x$ds*!4F`zobH;vn7RF-WY)2o
z>;6)ZSWg*)=Rtt@iGxZ=TtkJ#aB|?GZ4O{}~KsPlX^&edL5T|ceu`g?lSOCt{T=gTA7&=!jC(O?#mPs_Q}O&
zZ2RtLV38TY`T&}11B@fw$Cg~gOxxNoNEZ>?Vz#YfHmOD{7+h*pX*lknvvnt_L=s7
z1m52kalgZoyP5X?LJRX;DIhWG=cWkxv}Hrz_{D#EQEkTWTDe^;UZ65m5mNV`dCGg%dEGj{r_;_J#M)XbHBs28E7aHFWCDwYGpCLajQp$cCOyftTk>c7g4xb-&OA|AOgPT0p;%h-(iPdg=db;P
zd!K!1%V#9kz1ju9Z;Aryak1I}%@=h)j)`BU`P+62_v8v+-SNwER}1+(b7w^K_=4_t
z1pRXhEP>l3=pXxjwt+ta-mE52J=CxQYT#rt2
z)tt!-F5c_uVZ@~IX7q(U(mO}Er}aBFyMThS+(LU{E}?KDr$e~?q3txEulL;n5uy1QLlBX2{zcfiUs<)YEL54J80A(`L
z3VYdMP%T<$tvo);l;ZyV-XSQG^0pD1+K*}u0Dv#XP0U(}@8!wud3q*!V<6hxC{2o>
z$5t2Qr4jfVWYfz5(h+ALZAP$;frw$KIi^`>b`I7(mQXDJMh^Qa;M-HsWT78ph7Qcb
z@jLdjfhU~0l|qv@km{(~9IOI;p9%r*HN$2JzfO2OHuq$kv5s-V%(M|XSHg`zILu?s
zMhZzrgVP8UVB1HuHTTdj%)x-9)gb%irKOnBVnKhX0CR>3}Et;RHX$bo-zPI6-@7MN%
z9^3BQ-b9&Jx}C~5CZ_;?ZRDT&;Q#6~_Y=?9H<>staBse9#lmk&0CX;l>-y#hl~~%*
z(mhzJhr2#4w(rZgtkfkIb~gd=
zhV}D#8zh(G_@kR}?v?eyUi{)uij;jU=^_ajO=OP9*r)o-T`_fRtij12&)Oi
zlOS6oae_9;9T}jgd9W0;(7aCgV$cP!bt@osc`g$!&qYO$!bPGFsOx(
zE|Dr(XL9PATEDaz#5r+{4q$NYGp{Yua~SzUWa?%LgjoV}5?os(QWxF?XVs=O#C4er
zG5W!**AO;vY5k{x-v1~Rnj7EswwVs$GuM_6VGZHjZS*#J+p8A*t^9^6e;wz~i)jn{
z6s+yLS^$>j`H7b9!IpMpx(AE=c>qyAO!UTO9bd2<|EBrhCi>F4eYXk#5BFR@PhN~9
zZ;bmt6O@rtKjWpx-!Wf!;{O|D!EOen8NtF~@
zxF?mV5mEp!4%-0FG72S8ebkNDP-0?8m|T>;Is|XEM%y4qQ1;K9#AR)Qc>tl^ulugy
z0gTtTKTi`ql@3vcbuNgO@dR?Yz>4HL1mRGf&Nn#?Nlm{;@9=l;6xk9|mH}QY+M`*o
z5a`#nF-^Q?Hfm}c_&)0zsrf@7^|aBXhZxahUw0=DU08pv{!OlHGBhA271qW?4#T3+=U5WKHK-saQrgAzfnJy-+u?e
zU{OD&@iWZRCVrXb?=yW@$FK4Kw>7(#WND@@j}>Uq38HOmrz*{%nt5(;4atS;Lx3PDoc~o;
z%vr(&=^cXl)>0KY>s+aYCF2`2x1*x$r1cca_L`|~OI{ZL~DuK;Oe`eK>`_
zRNt-66Nj|fz&*k!6&XtW4$GsCkFh$DMp|0hh?&!bV(x-h?0tXs{g1s*ex0JPI{5z$
z<^qDGL&rq>+OG?-e-#QQ0J1!AivBT<{w^kblbGeV
zgoavf|NUjZx5xUM0Jw4`Am`}nvp-uDP5g%-M@zc{ao`KNI5jkLfBesxot)@}AUhwc
zF|^Rj{3HIs>{QVN#mc*bddxWqb>Q{|3p)bsIiZwPg>wT74wKN(g=a1^V@d&Z6AvoP
z1LX9|kTfSWuqjT(rXjiRaLhwkWu_BHjAT$wia;lIk&TXL=MOCU9jmjkX>=R%3PQ?*=x6DPN(%;#Thd67!SzzS
z3Ip3_XNRy2-6Sr;2jD$0SR@C_Koh0~2+%{S3voq6LjjGvMsafFl8R>_CAKVlj_7lH
z(qI&D1MOfPeo+FWM*}R-I0i*=H)rgM7ISS#Yew>kI#+JEK8#M;%@jV=lw#;87M)*!
zrQj63<7rp}J18}z8x?{^CorWH2hy7mqG>>Ka$WQ(wBf>GiR~(>mAJNV+T#w}?DGWY
z+3+%9x{G@7Sj!t^RFGYBWw##sz6a&~pE!=&rr$GGP|y_euOcew>$U(WiOEyOkI3)O
z#a+0}qlJAwa1pb81=&7V`sWLmW!U0gmhQpQJlnCH?Mv;SDV{C%e-rg%`TZ{k^`p@G
z&jehKGf)4@A6zf?of50LiZFyjYIY65I|j`4x%>&{-kIUNwTmpwHA~8c4#O$JRimPP
zYNamrf*BQIB#wrr8Dzb1>_hKoU(U3~AlrT201BAMpgF14dc;ps9a7p&h22eB4+%yw
zI10zM)Alob5i$$ygC-Tq6qOvEcm`+4Oex{i24KcOo9LRBo30FFdpn#C`f3nXqr3BJS2>WePMZWku@-sm@_eEjSk-JvT
zGl(jqdFJGTFwpKZ{FAeS5du1~bX(*J(wX6=n4~qq$)_{f9*kDOGB=Lm3MHKh{_LN8
z&+N;gm*r5G5Dt(>+b%_=2JcPU4B*Kiu=pLCQ30LzVNh;kzVG)w9aeycx=T2NJza#f
zF8->4|F2U3Krz++JHEun3h?uoJ2COgWBx(=JPq#usmEpdgs&C+pIo<}y`yIQTDj|u
zSl73udN?~9^|2@XnSYG45yAohRexhc>wRUzdV%z>JLz3iU}X4Ik~A(Qi_2%Il8ks
z+u*10@iVoU0}8dt{>p|RYE!@mpf4fmf=9dIrL*D_wd>ughh$2!(
z^w>p~mh_E)Zpq@7P76hJP^>qA707C?z^wCIw2-He;5%Z*(WHm6k4uYq6KeFOHO$s_
zTY@~$RvT1fux~5sGlomE!n42MZ;6y>_huc>7S_`
z2*4u;WBeQObMbx$(l&H~0lZB2Kvz>S19;LwIgt2}(BP`W6l$98Zx+?I00C1%hoxJM
z3I{Z;>QZ=wlf5L7Dl|)keX6$m_wyL&f_*+pE3;&$fiy~909KHm)@ofVO{J)5S+I!c
zo|tq@a=#?=o@
zAB@Eha{|}c!xxV%qMRTW)_@{{2bL`Ic}Hq!E&3)SeZ)0+|9&Dh2LQk6NcM;#pHL=g
z5x4~aN3c>b?OUU>ouys|9q`qY>g8lmg3R{L%0kHTp!FA4`K+7SZ>|=0Mw3PjKL=jX
zh`R1h=7)%T>xVvs>Tn%lkynoe@HJKx{>t`XJ^Yi!gn$V}kNPp~f7SAh`#-(P*Xd+$
z#{Iu=n~YnYT>eF%YHWlH=52G-zTveW6{UxZl|d!4TZip26+$YxETLZ
zN?Y{m_Pwa^BjLDG3G!X+kz9g306HfI3tk?@LHyniOlou2>4woTu+Zaj*-N~0x$
z^SJ=_-L~zJ{5J9iDHF~q@&)l&3_}{>f(h=~F|&QDp)yDz<^i-m2_eGi>6(59sknb=
z_|g){DTJOP(7_$TZLUD%1JX(|w@1a+&nwHx`#Ge{#>~;C?8wl%NKUPtx`VF@%u(w*yW|r
z{;wd(+cg7zSO7jx<>aNG2xuqN<%khtL=Lf3T{VZEcC`T7ApjQ`V^%pd`YEM}xMp8r
zhXrp*cA(9O0BQ+!UfiYAH~H7-lL7_%Zaun#hwkGoDhz!7*63VB)zdO#$8~E|ZPJ
z=pCq^Gpmma(<(~jpxO+CZDjpS;H5%FZQDzw-<}(dx{k$X){zumSW8n%)8;#rneR$B
z&sU-V$z8%wQYkGOxUXPX%JZBmWVNzNW1|$(q}o4T+)VK?+IOV2%cna34s9*sREIa8rgo6LUjAZ-Imk?#R
zW`>B)N(F(xg=r$N&l@&VP1Jb=>C>}^o2iv;6tlANeaeQ3$i_sc^H;Z@ZHEY%xng(F
zXF2qtJV`o$C!$NFIzSugXO~@C0sc87G~TeWb>~-n(Xai`Mgh%;`pJb}9gyMBIY)&803lQVw%;_f>{{ZRAw9RD|krq5hI*YMr8-SomIfBU%n<)6N`W%AIB%8B|+C>!Vs)S;FV2zV#bw1^ZS%J0zLbH=dkgH@o&S)_YSO~Jk3
zKJjh+vlw*Fb71Hyo#IyZL$02zR=__lE5HiVMVF9zWm^Nkp^0GmM8~njqecJNPg~{7
zEC6Qv{3j3$M*rOVjtKj--{k$DzkSu{??Bk+a|Qm-LHjrGckX|6_P+dbyf4%IJ^WLk
z{a<tIr_yHLg;Usk`>y?$GkDTJAe99aegt9orYDkHupF6Z3~iIh
zR?XjM7c(1cE)YXtg;hhq&V+KyB3zUwa{TwfS?#^C$Xnl&_HfxX+p)gYj@?N)ec(&c
z*{RQVPmWJeR%ds+4}IT*hzR2Cd4JhXz<;HdufqvkSn7{^9A7`Nf(mzK1ylwRZybuvTCIrm!YkLXJ&)qxcwLd_hnjEk`=$S5%6|QeYFAf~#XHpZ4t{Zr0kd=b45!{a-e(r#i
zj(x_tYMot4iP>SH=AwJ%AVpeWN9;uxoe3&_An^hrqoipR2}Cu4O%vdv<2T2F6kWr{
z7*)4cFKM7okebeW)q7f{Zj8W9rgjV
zZGekxXE$^70$a;IC~Btj>J%%YIU`xRqdEl?!iTC3sWH%6lVkKdB(AHIkwxjlH`S+5vTL$2k?eBbZ>@{{*TD}|G<4oC36HVkymn%PFJ?!ww8CiyJB
z-@^TUrLXm$Cr*WZp1#&S>)%W3-)(UGKKrc59qVt)_-v$qSPcTX^`$TTDZ8#Fc#BG^^!>x$aIB0`q1$B*C^T@bb2Na%dO{3T0=(c@
z$2g(Di2KaM0dl~|M8N$cnVTr1A_g5}qJcHT?V|lrRUYfro2eBlNsF0?tF4ZCNK9Z-
z4iA?(nE-+p$HQQYB_HEhi(9kp*iZ>pnkGB@4Q8jd=veMa4M{>CYSLOMWd>&y&uHI8
z!Iud?q_-n}kfI9Vi-c`aMQOGCFfMi?NAFmPGKBQG5>+eT&3u;UkcBrgJ!6
z2G1^J*UfWiiwGcyOuj^AYeio{--yZ6KGjyq_OQwP8iV8!>gob+U@qGbzGpw)`kx0m
zi{ZR2G!Sd!adx*8mOfKfQhRZ~zB^`PB)}q=RAzYG%uWyTljGoI32F&I%-QHZj+O?Y
zOFa_@koB*ewT)*ilQzDj87%?W3-S@rRm!YXH$mw)0D
z56cD4d%8e`&=e#bgV%McS3>z3Er6+;sGsBbSRdRU>hAS`^Vns%{tf&s?2}78+N@vG
z{$E-`P;kbtW%?Z8{w?PZsh_iNsL5St@VURKob~(FuQ0AYeaG5^e%qE-@v0_XkrYbQ
z06J1Tpgx!}(diS2p94-?+r;q9H0cAr;!jK4%Io9mOU4%Wt{J=O>srVcXwe3VIU5bO
z`g8jpk3!8fX$%}AP$IQ^MhSm821;r&!P@$Jzcdp7C545^MsjA|3*WMGC2~|KC6rPd
zgHDCoHQQGur8@EvZQHmug=SV7b*!>g&+jsoG4ugyA}PFQdp>QISH3Xl-y-Ea&SSN1
zQkwW|b!FOcUJKXF4pGVg8it_B7ncGvyPXnSIVmxyACa6Ox5xW6O+KyIFd-L47x)iM
zf^==-K8P?p!PM9xWW^T@HE-j^5j5GWcxp2JXb-XE=JIFF*sQ%>t
z=Ka5V7t%%hCAn5WydIseUr`NYL|Xy$sm$HG1@
z#dn5%uGlZTH`P6_TBgsh#42B;eopXb`?0L2O*V0@->d-3Bea}dkA3O$?_XckciMrY
zIen36(
z!4g^p^FIiM%B1tJy@WR7HKhnWWHZ5DJFrAu3-AC&ny+oJzw?u$qP}#O4Nyq+5Yx(ra}Sk2>Pc|gf@9V3sSP}lOED~7ksxV+;~q#pW(BXc{w)?4MD}(=sx=w
z^Fk=}^L^m>mFJIH=UHD;p@TDx6>C}KqVWq;M4b2t@l2n*F#+E#+3lPFn<*UD=_0vU
zeWZn$p1UGaO3clwVm0UB6xhqGnb0EX+mQA;Q*&1cf!Ri#%0>b6ZzGie2vZ0H)J4|5vygR#<=oELZeK62{(
z7w?T_9__n@eZKV$8DD7HzpUWDyLGhy)_prn{37>n^p81w_gLh`;{Kfun!X0Wy5ogE
z6~jo|`_Sz`k}|_pS~4{tn13uxxRYnl>!#%Pak?(v3>@s5gEHppjE#nxwU`9CNx(qs
z(AQRu;DzePB!EbyA)&IPvZ3@Pxgb
z+jIRJ_}~7P_`cFaV%a{=HiFecKkbvb{;e4ByAW9ac2{H`y&Tk!Mf@03V3|Tsp8Di(
zTPygT1^f7P{2Qqr2BqVjf3~+5qlt;}2Z+JA06ydBksV{qoG!mW=*+H7Xo7;vOg7!3
zl@UT@6Opq3m?3{KNN3C*RcQ)-f(*JSq<{h;6F&e*lbURcUfSx63EBj@jX+pt%0dxM
zh6Iy}O0uc8H%T2;8`7ohL(^)!o8qk|-c<>XQJ1U3w!UQosBCT>=O?|=|3!J~J`hVAY
z7{T9G`m*xmli~FBxc~L<%u46@TE8p!Uw&TZO_uK;%RG9ne8!e
z$Hvv9Ve$teVa&cu?GqU;B*`NwuwwD#2Imkf#X3sLL$?U;1t0PoQol956b
z4$#PR5sg?&I6h;I0cBX|Oz>}?LYikz>GJ^U`;^|Ha#9<+Ir}PqsH6aC{SUimy$t2y
zGX=$@6~N15Cbavo&MZI14EVk#`V1_{OTHrE|JM-!dP>Q%^r!Y|A)gHUw9rCw@N0p!sxVBcdFX6p+zQw>uuLYxiCUu5B@t}UKXbfG}7TjX)j_AL~&`3N{>A_P@y;d*XA1@Jk>`a93&qV_+l
zOH~KS_8>K-Eoq%^_b4pOL@Yo4JHGPx9i|+6s%Vvhq*rkHbu56n!41#)X^&-M&i;D3
zXYtv<@~KbN**5D}sQtgGM#Xc_#bPi1eG