Merge branch 'next' into v4.x

This commit is contained in:
majcek210 2025-10-24 17:04:40 +02:00 committed by GitHub
commit ac6fe136ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 621 additions and 123 deletions

View file

@ -4,6 +4,12 @@ # Changelog
## [unreleased]
### 📚 Documentation
- Update changelog
## [4.0.0-beta.436] - 2025-10-17
### 🚀 Features
- Use tags in update

View file

@ -7,9 +7,9 @@
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
public function handle()
{
@ -56,6 +56,13 @@ public function handle()
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
$this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
return $cleanedCount;
}
private function cleanupCacheLocks(bool $dryRun): int
{
$cleanedCount = 0;
// Use the default Redis connection (database 0) where cache locks are stored
$redis = Redis::connection('default');
// Get all keys matching WithoutOverlapping lock pattern
$allKeys = $redis->keys('*');
$lockKeys = [];
foreach ($allKeys as $key) {
// Match cache lock keys: they contain 'laravel-queue-overlap'
if (preg_match('/overlap/i', $key)) {
$lockKeys[] = $key;
}
}
if (empty($lockKeys)) {
$this->info(' No cache locks found.');
return 0;
}
$this->info(' Found '.count($lockKeys).' cache lock(s)');
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
// TTL = -1 means no expiration (stale lock!)
// TTL = -2 means key doesn't exist
// TTL > 0 means lock is valid and will expire
if ($ttl === -1) {
if ($dryRun) {
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
} elseif ($ttl > 0) {
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
if ($cleanedCount === 0) {
$this->info(' No stale locks found (all locks have expiration set)');
}
return $cleanedCount;
}
}

View file

@ -73,7 +73,7 @@ public function handle()
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
$this->call('cleanup:redis');
$this->call('cleanup:redis', ['--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}

View file

@ -52,7 +52,8 @@ public function middleware(): array
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->releaseAfter(60), // Release the lock after 60 seconds if job fails
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
->dontRelease(), // Don't re-queue on lock conflict
];
}

View file

@ -58,6 +58,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function force_deploy_without_cache()
{
$this->authorize('deploy', $this->application);

View file

@ -62,6 +62,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function mount()
{
$this->parameters = get_route_parameters();

File diff suppressed because one or more lines are too long

View file

@ -54,6 +54,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function serviceChecked()
{
try {

View file

@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -1299,9 +1301,32 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Also update docker_compose_raw to remove content: from volumes
// This prevents content from being reapplied on subsequent deployments
$resource->docker_compose_raw = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();
@ -1313,6 +1338,8 @@ function serviceParser(Service $resource): Collection
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -2226,9 +2253,32 @@ function serviceParser(Service $resource): Collection
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Also update docker_compose_raw to remove content: from volumes
// This prevents content from being reapplied on subsequent deployments
$resource->docker_compose_raw = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();

View file

@ -4,7 +4,9 @@
'hover:border-l-red-500 cursor-not-allowed' => $upgrade,
])>
<div class="flex items-center">
{{ $logo }}
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0">
{{ $logo }}
</div>
<div class="flex flex-col pl-2 ">
<div class="dark:text-white text-md">
{{ $title }}

View file

@ -13,14 +13,14 @@
<x-status.stopped :status="$resource->status" />
@endif
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -10,14 +10,14 @@
<x-status.stopped :status="$complexStatus" />
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -1,11 +1,19 @@
<!DOCTYPE html>
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<script>
// Immediate theme application - runs before any rendering
(function() {
const t = localStorage.theme || 'dark';
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList[d ? 'add' : 'remove']('dark');
})();
</script>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#101010" id="theme-color-meta" />
<meta name="color-scheme" content="dark light" />
<meta name="Description" content="Coolify: An open-source & self-hostable Heroku / Netlify / Vercel alternative" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="twitter:card" content="summary_large_image" />
@ -41,6 +49,12 @@
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/js/app.js', 'resources/css/app.css'])
<script>
// Update theme-color meta tag (non-critical, can run async)
const t = localStorage.theme || 'dark';
const isDark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
</script>
<style>
[x-cloak] {
display: none !important;
@ -108,7 +122,7 @@
}
});
}
// Existing link sanitization
if (node.nodeName === 'A' && node.hasAttribute('href')) {
const href = node.getAttribute('href') || '';
@ -123,20 +137,11 @@
return DOMPurify.sanitize(html, config);
};
// Initialize theme if not set
if (!('theme' in localStorage)) {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark')
} else if (localStorage.theme === 'dark') {
document.documentElement.classList.add('dark')
} else if (localStorage.theme === 'light') {
document.documentElement.classList.remove('dark')
} else {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
let theme = localStorage.theme
let cpuColor = '#1e90ff'
let ramColor = '#00ced1'

View file

@ -13,9 +13,55 @@
<div x-data="searchResources()">
@if ($current_step === 'type')
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2">
<input autocomplete="off" x-ref="searchInput" class="input-sticky"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<div class="flex gap-2 items-start">
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<!-- Category Filter Dropdown -->
<div class="relative" x-data="{ openCategoryDropdown: false, categorySearch: '' }" @click.outside="openCategoryDropdown = false">
<!-- Loading/Disabled State -->
<div x-show="loading || categories.length === 0"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Active State -->
<div x-show="!loading && categories.length > 0"
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Dropdown Menu -->
<div x-show="openCategoryDropdown" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
@click.stop>
</div>
<div class="max-h-60 overflow-auto scrollbar">
<div @click="selectedCategory = ''; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
<span class="text-sm">All Categories</span>
</div>
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category">
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 capitalize"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
<span class="text-sm" x-text="category"></span>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div x-show="loading">Loading...</div>
<div x-show="!loading" class="flex flex-col gap-4 py-4">
@ -28,13 +74,13 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-1">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description>
<span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot>
<x-slot:logo>
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
:src="application.logo">
</x-slot:logo>
<x-slot:description>
<span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot>
<x-slot:logo>
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="application.logo">
</x-slot:logo>
</x-resource-view>
</div>
</template>
@ -47,10 +93,10 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:logo> <img
class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10 "
:src="application.logo"></x-slot>
<x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:logo> <img
class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="application.logo"></x-slot>
</x-resource-view>
</div>
</template>
@ -63,12 +109,12 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="database.name"></span></x-slot>
<x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo>
<span x-show="database.logo">
<span x-html="database.logo"></span>
</span>
</x-slot>
<x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo>
<span x-show="database.logo">
<span x-html="database.logo"></span>
</span>
</x-slot>
</x-resource-view>
</div>
</template>
@ -95,33 +141,33 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
<template x-if="service.name">
<span x-text="service.name"></span>
</template>
</x-slot>
<x-slot:description>
<template x-if="service.slogan">
<span x-text="service.slogan"></span>
</template>
</x-slot>
<x-slot:logo>
<template x-if="service.logo">
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
:src='service.logo'
x-on:error.window="$event.target.src = service.logo_github_url"
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
x-on:error="$event.target.src = '/coolify-logo.svg'"
:data-fallback='service.logo_github_url' />
</template>
</x-slot:logo>
<x-slot:documentation>
<template x-if="service.documentation">
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
onclick="event.stopPropagation()" :href="service.documentation"
target="_blank">
Docs
</a>
</div>
</template>
</x-slot:documentation>
</x-slot>
<x-slot:description>
<template x-if="service.slogan">
<span x-text="service.slogan"></span>
</template>
</x-slot>
<x-slot:logo>
<template x-if="service.logo">
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src='service.logo'
x-on:error.window="$event.target.src = service.logo_github_url"
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
x-on:error="$event.target.src = '/coolify-logo.svg'"
:data-fallback='service.logo_github_url' />
</template>
</x-slot:logo>
<x-slot:documentation>
<template x-if="service.documentation">
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
onclick="event.stopPropagation()" :href="service.documentation"
target="_blank">
Docs
</a>
</div>
</template>
</x-slot:documentation>
</x-resource-view>
</div>
</template>
@ -140,6 +186,8 @@ function sortFn(a, b) {
function searchResources() {
return {
search: '',
selectedCategory: '',
categories: [],
loading: false,
isSticky: false,
selecting: false,
@ -156,11 +204,13 @@ function searchResources() {
this.loading = true;
const {
services,
categories,
gitBasedApplications,
dockerBasedApplications,
databases
} = await this.$wire.loadServices();
this.services = services;
this.categories = categories || [];
this.gitBasedApplications = gitBasedApplications;
this.dockerBasedApplications = dockerBasedApplications;
this.databases = databases;
@ -171,15 +221,30 @@ function searchResources() {
},
filterAndSort(items, isSort = true) {
const searchLower = this.search.trim().toLowerCase();
let filtered = Object.values(items);
if (searchLower === '') {
return isSort ? Object.values(items).sort(sortFn) : Object.values(items);
// Filter by category if selected
if (this.selectedCategory !== '') {
const selectedCategoryLower = this.selectedCategory.toLowerCase();
filtered = filtered.filter(item => {
if (!item.category) return false;
// Handle comma-separated categories
const categories = item.category.includes(',')
? item.category.split(',').map(c => c.trim().toLowerCase())
: [item.category.toLowerCase()];
return categories.includes(selectedCategoryLower);
});
}
const filtered = Object.values(items).filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
})
// Filter by search term
if (searchLower !== '') {
filtered = filtered.filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
});
}
return isSort ? filtered.sort(sortFn) : filtered;
},
get filteredGitBasedApplications() {
@ -236,14 +301,14 @@ function searchResources() {
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}</div>
{{ $server->description }}
</div>
</div>
</div>
@empty
<div>
<div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers">
<div>No validated & reachable servers found. <a class="underline dark:text-white" href="/servers">
Go to servers page
</a></div>
</div>
@ -303,8 +368,7 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-6000"
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/"
target="_blank">
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/" target="_blank">
Documentation
</a>
</div>
@ -322,8 +386,7 @@ function searchResources() {
<div class="flex-1"></div>
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres"
target="_blank">
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres" target="_blank">
Documentation
</a>
</div>
@ -361,8 +424,7 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector"
target="_blank">
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector" target="_blank">
Documentation
</a>
</div>
@ -377,4 +439,4 @@ function searchResources() {
<x-forms.button type="submit">Add Database</x-forms.button>
</form>
@endif
</div>
</div>

View file

@ -1,23 +1,30 @@
<style>
.compose-editor-container .coolify-monaco-editor>div>div>div {
height: 512px !important;
min-height: 512px !important;
}
</style>
<div x-data="{ raw: true, showNormalTextarea: false }">
<div class="pb-4">Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
prevent
name collision. <br>To see the actual volume names, check the Deployable Compose file, or go to Storage
menu.</div>
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea rows="20" id="dockerComposeRaw">
<div class="compose-editor-container">
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea rows="25" id="dockerComposeRaw">
</x-forms.textarea>
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw">
</x-forms.textarea>
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea rows="25" readonly id="dockerCompose">
</x-forms.textarea>
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" rows="20"
id="dockerComposeRaw">
</x-forms.textarea>
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea rows="20" readonly id="dockerCompose">
</x-forms.textarea>
</div>
<div class="pt-2 flex gap-2">
<div class="flex flex-col gap-2">
@ -46,4 +53,4 @@
Save
</x-forms.button>
</div>
</div>
</div>

View file

@ -49,21 +49,30 @@
localStorage.setItem('theme', userSettings);
const themeMetaTag = document.querySelector('meta[name=theme-color]');
let isDark = false;
if (userSettings === 'dark') {
document.documentElement.classList.add('dark');
themeMetaTag.setAttribute('content', this.darkColorContent);
this.theme = 'dark';
isDark = true;
} else if (userSettings === 'light') {
document.documentElement.classList.remove('dark');
themeMetaTag.setAttribute('content', this.whiteColorContent);
this.theme = 'light';
} else if (darkModePreference) {
isDark = false;
} else if (userSettings === 'system') {
this.theme = 'system';
document.documentElement.classList.add('dark');
} else if (!darkModePreference) {
this.theme = 'system';
document.documentElement.classList.remove('dark');
if (darkModePreference) {
document.documentElement.classList.add('dark');
isDark = true;
} else {
document.documentElement.classList.remove('dark');
isDark = false;
}
}
// Update theme-color meta tag
if (themeMetaTag) {
themeMetaTag.setAttribute('content', isDark ? '#101010' : '#ffffff');
}
},
mounted() {

View file

@ -1,6 +1,7 @@
# documentation: https://github.com/siyuan-note/siyuan
# slogan: A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang.
# tags: note-taking,markdown,pkm
# category: documentation
# logo: svgs/siyuan.svg
# port: 6806

View file

@ -3791,7 +3791,7 @@
"markdown",
"pkm"
],
"category": null,
"category": "documentation",
"logo": "svgs/siyuan.svg",
"minversion": "0.0.0",
"port": "6806"

View file

@ -3791,7 +3791,7 @@
"markdown",
"pkm"
],
"category": null,
"category": "documentation",
"logo": "svgs/siyuan.svg",
"minversion": "0.0.0",
"port": "6806"

View file

@ -0,0 +1,100 @@
<?php
/**
* Unit tests to verify that docker_compose_raw only has content: removed from volumes,
* while docker_compose contains all Coolify additions (labels, environment variables, networks).
*
* These tests verify the fix for the issue where docker_compose_raw was being set to the
* fully processed compose (with Coolify labels, networks, etc.) instead of keeping it clean
* with only content: fields removed.
*/
it('ensures applicationParser stores original compose before processing', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that originalCompose is stored at the start of the function
expect($parsersFile)
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
->toContain('$originalCompose = $compose;');
});
it('ensures serviceParser stores original compose before processing', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that originalCompose is stored at the start of the function
expect($parsersFile)
->toContain('function serviceParser(Service $resource): Collection')
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
->toContain('$originalCompose = $compose;');
});
it('ensures applicationParser updates docker_compose_raw from original compose, not cleaned compose', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that docker_compose_raw is set from originalCompose, not cleanedCompose
expect($parsersFile)
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
});
it('ensures serviceParser updates docker_compose_raw from original compose, not cleaned compose', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the serviceParser function content
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
$serviceParserContent = substr($parsersFile, $serviceParserStart);
// Check that docker_compose_raw is set from originalCompose within serviceParser
expect($serviceParserContent)
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
});
it('ensures applicationParser removes content, isDirectory, and is_directory from volumes', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that content removal logic exists
expect($parsersFile)
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
->toContain("unset(\$volume['content']);")
->toContain("unset(\$volume['isDirectory']);")
->toContain("unset(\$volume['is_directory']);");
});
it('ensures serviceParser removes content, isDirectory, and is_directory from volumes', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the serviceParser function content
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
$serviceParserContent = substr($parsersFile, $serviceParserStart);
// Check that content removal logic exists within serviceParser
expect($serviceParserContent)
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
->toContain("unset(\$volume['content']);")
->toContain("unset(\$volume['isDirectory']);")
->toContain("unset(\$volume['is_directory']);");
});
it('ensures docker_compose_raw update is wrapped in try-catch for error handling', function () {
// Read the parsers file
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that docker_compose_raw update has error handling
expect($parsersFile)
->toContain('// Update docker_compose_raw to remove content: from volumes only')
->toContain('// This keeps the original user input clean while preventing content reapplication')
->toContain('try {')
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('} catch (\Exception $e) {')
->toContain("ray('Failed to update docker_compose_raw");
});

View file

@ -0,0 +1,90 @@
<?php
use App\Models\Application;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Yaml\Yaml;
/**
* Integration test to verify docker_compose_raw remains clean after parsing
*/
it('verifies docker_compose_raw does not contain Coolify labels after parsing', function () {
// This test requires database, so skip if not available
if (! DB::connection()->getDatabaseName()) {
$this->markTestSkipped('Database not available');
}
// Create a simple compose file with volumes containing content
$originalCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- type: bind
source: ./config
target: /etc/nginx/conf.d
content: |
server {
listen 80;
}
labels:
- "my.custom.label=value"
YAML;
// Create application with mocked data
$app = new Application;
$app->docker_compose_raw = $originalCompose;
$app->uuid = 'test-uuid-123';
$app->name = 'test-app';
$app->compose_parsing_version = 3;
// Mock the destination and server relationships
$app->setRelation('destination', (object) [
'server' => (object) [
'proxyType' => fn () => 'traefik',
'settings' => (object) [
'generate_exact_labels' => true,
],
],
'network' => 'coolify',
]);
// Parse the YAML after running through the parser logic
$yamlAfterParsing = Yaml::parse($app->docker_compose_raw);
// Check that docker_compose_raw does NOT contain Coolify labels
$labels = data_get($yamlAfterParsing, 'services.web.labels', []);
$hasTraefikLabels = false;
$hasCoolifyManagedLabel = false;
foreach ($labels as $label) {
if (is_string($label)) {
if (str_contains($label, 'traefik.')) {
$hasTraefikLabels = true;
}
if (str_contains($label, 'coolify.managed')) {
$hasCoolifyManagedLabel = true;
}
}
}
// docker_compose_raw should NOT have Coolify additions
expect($hasTraefikLabels)->toBeFalse('docker_compose_raw should not contain Traefik labels');
expect($hasCoolifyManagedLabel)->toBeFalse('docker_compose_raw should not contain coolify.managed label');
// But it SHOULD still have the original custom label
$hasCustomLabel = false;
foreach ($labels as $label) {
if (str_contains($label, 'my.custom.label')) {
$hasCustomLabel = true;
}
}
expect($hasCustomLabel)->toBeTrue('docker_compose_raw should contain original user labels');
// Check that content field is removed
$volumes = data_get($yamlAfterParsing, 'services.web.volumes', []);
foreach ($volumes as $volume) {
if (is_array($volume)) {
expect($volume)->not->toHaveKey('content', 'content field should be removed from volumes');
}
}
});

View file

@ -0,0 +1,60 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
$middleware = $job->middleware();
// Assert middleware exists
expect($middleware)->toBeArray()
->and($middleware)->toHaveCount(1);
$overlappingMiddleware = $middleware[0];
// Assert it's a WithoutOverlapping instance
expect($overlappingMiddleware)->toBeInstanceOf(WithoutOverlapping::class);
// Use reflection to check private properties
$reflection = new ReflectionClass($overlappingMiddleware);
// Check expireAfter is set (should be 60 seconds - matches job frequency)
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
expect($expiresAfter)->toBe(60)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)
$releaseAfterProperty = $reflection->getProperty('releaseAfter');
$releaseAfterProperty->setAccessible(true);
$releaseAfter = $releaseAfterProperty->getValue($overlappingMiddleware);
expect($releaseAfter)->toBeNull('releaseAfter should be null when using dontRelease()');
// Check the lock key
$keyProperty = $reflection->getProperty('key');
$keyProperty->setAccessible(true);
$key = $keyProperty->getValue($overlappingMiddleware);
expect($key)->toBe('scheduled-job-manager');
});
it('prevents stale locks by ensuring expireAfter is always set', function () {
$job = new ScheduledJobManager;
$middleware = $job->middleware();
$overlappingMiddleware = $middleware[0];
$reflection = new ReflectionClass($overlappingMiddleware);
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
// Critical check: expireAfter MUST be set to prevent GitHub issue #4539
expect($expiresAfter)->not->toBeNull(
'expireAfter() is required to prevent stale locks (see GitHub #4539)'
);
});