git stacks can now set a custom icon

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:37 +02:00
1 parent 58f712d432
commit fdca208795
4 files changed
+115 -15

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
/**
* Persist a stack icon to the name-based /api/stacks/[name]/icon endpoint. Shared by
* StackModal and GitStackModal so the DELETE/upload/set contract lives in one place. The
* caller builds `target` (it owns env resolution and create-vs-edit gating); `value` is
* the picker output: '' -> clear (DELETE), 'upload:<dataUrl>' -> custom upload, else a
* lucide/selfhst ref. Returns the new icon on success, null after a clear, and undefined
* when a POST failed (so the caller leaves the current icon untouched).
*/
export async function persistStackIcon(target: string, value: string): Promise<string | null | undefined> {
if (!value) {
await fetch(target, { method: 'DELETE' });
return null;
}
const body = value.startsWith('upload:')
? { image: value.slice('upload:'.length) }
: { icon: value };
const res = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return res.ok ? (await res.json()).icon : undefined;
}
+44 -4
View File
@@ -17,6 +17,10 @@
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
import BranchCombobox from './BranchCombobox.svelte';
import IconPickerModal from './IconPickerModal.svelte';
import StackIcon from '$lib/components/StackIcon.svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { persistStackIcon } from '$lib/utils/stack-icon';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
@@ -72,13 +76,32 @@
open: boolean;
gitStack?: GitStack | null;
environmentId?: number | null;
icon?: string | null;
repositories: GitRepository[];
credentials: GitCredential[];
onClose: () => void;
onSaved: () => void;
}
let { open = $bindable(), gitStack = null, environmentId = null, repositories, credentials, onClose, onSaved }: Props = $props();
let { open = $bindable(), gitStack = null, environmentId = null, icon = null, repositories, credentials, onClose, onSaved }: Props = $props();
// Per-stack icon override (same name-based /icon endpoint as internal stacks, #1473).
let formIcon = $state<string | null>(icon);
let showIconPicker = $state(false);
$effect(() => { formIcon = icon; });
// value: '' clear, 'upload:<dataUrl>' custom upload, or a lucide name / 'selfhst:<ref>'.
async function onIconSelect(value: string) {
if (!gitStack?.stackName) return;
const target = appendEnvParam(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/icon`, effectiveEnvId);
try {
const next = await persistStackIcon(target, value);
if (next !== undefined) formIcon = next; // undefined = POST failed, keep current
onSaved();
} catch (e) {
console.error('Failed to set stack icon:', e);
}
}
// Form state - repository selection or creation
let formRepoMode = $state<'existing' | 'new'>('existing');
@@ -746,9 +769,24 @@
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
{#if gitStack}
<button
type="button"
title="Change stack icon"
onclick={() => (showIconPicker = true)}
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
>
{#if formIcon}
<StackIcon icon={formIcon} stackName={gitStack.stackName} envId={effectiveEnvId} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
{:else}
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
{/if}
</button>
{:else}
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
{/if}
<div>
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
@@ -1424,3 +1462,5 @@
</div>
</Dialog.Content>
</Dialog.Root>
<IconPickerModal bind:open={showIconPicker} value={formIcon} onselect={onIconSelect} title="Choose a stack icon" />
+3 -11
View File
@@ -24,6 +24,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { Badge } from '$lib/components/ui/badge';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { persistStackIcon } from '$lib/utils/stack-icon';
import { appSettings } from '$lib/stores/settings';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { focusFirstInput } from '$lib/utils';
@@ -85,17 +86,8 @@
const envId = $currentEnvironment?.id ?? null;
const target = appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/icon`, envId);
try {
if (!value) {
await fetch(target, { method: 'DELETE' });
formIcon = null;
} else if (value.startsWith('upload:')) {
const image = value.slice('upload:'.length);
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image }) });
if (res.ok) formIcon = (await res.json()).icon;
} else {
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ icon: value }) });
if (res.ok) formIcon = (await res.json()).icon;
}
const next = await persistStackIcon(target, value);
if (next !== undefined) formIcon = next; // undefined = POST failed, keep current
onSuccess?.();
} catch (e) {
console.error('Failed to set stack icon:', e);
+45
View File
@@ -0,0 +1,45 @@
/**
* persistStackIcon: the shared DELETE/upload/set contract for the name-based stack icon
* endpoint (used by StackModal and GitStackModal, #1473). null after a clear, the new
* icon on success, undefined when a POST failed (so callers keep the current icon).
*/
import { describe, it, expect, afterEach } from 'bun:test';
import { persistStackIcon } from '../src/lib/utils/stack-icon';
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; });
function stubFetch(impl: (url: string, init?: any) => { ok: boolean; json?: () => any }) {
globalThis.fetch = (async (url: any, init?: any) => {
const r = impl(String(url), init);
return { ok: r.ok, json: async () => (r.json ? r.json() : {}) } as any;
}) as any;
}
describe('persistStackIcon', () => {
it('clears with DELETE and returns null for an empty value', async () => {
let method = '';
stubFetch((_u, init) => { method = init?.method; return { ok: true }; });
expect(await persistStackIcon('/api/stacks/x/icon', '')).toBeNull();
expect(method).toBe('DELETE');
});
it('POSTs {image} for an upload: value and returns the stored icon', async () => {
let body: any = null;
stubFetch((_u, init) => { body = JSON.parse(init.body); return { ok: true, json: () => ({ icon: 'custom:stack' }) }; });
expect(await persistStackIcon('/api/stacks/x/icon', 'upload:data:image/png;base64,AAAA')).toBe('custom:stack');
expect(body.image).toBe('data:image/png;base64,AAAA');
});
it('POSTs {icon} for a lucide/selfhst value and returns it', async () => {
let body: any = null;
stubFetch((_u, init) => { body = JSON.parse(init.body); return { ok: true, json: () => ({ icon: 'database' }) }; });
expect(await persistStackIcon('/api/stacks/x/icon', 'database')).toBe('database');
expect(body.icon).toBe('database');
});
it('returns undefined when the POST fails (caller keeps the current icon)', async () => {
stubFetch(() => ({ ok: false }));
expect(await persistStackIcon('/api/stacks/x/icon', 'database')).toBeUndefined();
});
});