Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
prevent
name collision.
To see the actual volume names, check the Deployable Compose file, or go to Storage
menu.
-
\ No newline at end of file
diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php
index 4072cd240..efd359098 100644
--- a/resources/views/livewire/settings-dropdown.blade.php
+++ b/resources/views/livewire/settings-dropdown.blade.php
@@ -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() {
diff --git a/templates/compose/siyuan.yaml b/templates/compose/siyuan.yaml
index 5654465bb..4d9f6c732 100644
--- a/templates/compose/siyuan.yaml
+++ b/templates/compose/siyuan.yaml
@@ -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
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 03ac03d1a..1629cc152 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -3791,7 +3791,7 @@
"markdown",
"pkm"
],
- "category": null,
+ "category": "documentation",
"logo": "svgs/siyuan.svg",
"minversion": "0.0.0",
"port": "6806"
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 4dcc3140b..13a6d7382 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -3791,7 +3791,7 @@
"markdown",
"pkm"
],
- "category": null,
+ "category": "documentation",
"logo": "svgs/siyuan.svg",
"minversion": "0.0.0",
"port": "6806"
diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php
new file mode 100644
index 000000000..159acb366
--- /dev/null
+++ b/tests/Unit/DockerComposeRawContentRemovalTest.php
@@ -0,0 +1,100 @@
+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");
+});
diff --git a/tests/Unit/DockerComposeRawSeparationTest.php b/tests/Unit/DockerComposeRawSeparationTest.php
new file mode 100644
index 000000000..bb6c8ca79
--- /dev/null
+++ b/tests/Unit/DockerComposeRawSeparationTest.php
@@ -0,0 +1,90 @@
+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');
+ }
+ }
+});
diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php
new file mode 100644
index 000000000..3f3ae593a
--- /dev/null
+++ b/tests/Unit/ScheduledJobManagerLockTest.php
@@ -0,0 +1,60 @@
+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)'
+ );
+});