git stack source badge with forge-aware icons and commit hash

This commit is contained in:
jarek committed 2026-07-31 10:10:56 +02:00
1 parent 9bc4eaa47b
commit c18ee21a66
9 files changed
+280 -76

No files matched your search

@@ -0,0 +1,10 @@
<script lang="ts">
// Generic git-repository glyph (non-GitHub/GitLab forge). The official Git
// logo (mdi-git) - distinct from lucide's GitBranch, which reads as a branch.
// currentColor so it inherits text color; `class` mirrors the lucide icon API.
let { class: className = '' }: { class?: string } = $props();
</script>
<svg class={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M2.6,10.59L8.38,4.8L10.07,6.5C9.83,7.35 10.22,8.28 11,8.73V14.27C10.4,14.61 10,15.26 10,16A2,2 0 0,0 12,18A2,2 0 0,0 14,16C14,15.26 13.6,14.61 13,14.27V9.41L15.07,11.5C15,11.65 15,11.82 15,12A2,2 0 0,0 17,14A2,2 0 0,0 19,12A2,2 0 0,0 17,10C16.82,10 16.65,10 16.5,10.07L13.93,7.5C14.19,6.57 13.71,5.55 12.78,5.16C12.35,5 11.9,4.96 11.5,5.07L9.8,3.38L10.59,2.6C11.37,1.81 12.63,1.81 13.41,2.6L21.4,10.59C22.19,11.37 22.19,12.63 21.4,13.41L13.41,21.4C12.63,22.19 11.37,22.19 10.59,21.4L2.6,13.41C1.81,12.63 1.81,11.37 2.6,10.59Z" />
</svg>
+28
View File
@@ -17,6 +17,7 @@ export interface AppSettings {
confirmDestructive: boolean;
showStoppedContainers: boolean;
highlightUpdates: boolean;
showGitCommitHash: boolean;
timeFormat: TimeFormat;
dateFormat: DateFormat;
downloadFormat: DownloadFormat;
@@ -59,6 +60,7 @@ const DEFAULT_SETTINGS: AppSettings = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
showGitCommitHash: false,
timeFormat: '24h',
dateFormat: 'DD.MM.YYYY',
downloadFormat: 'tar',
@@ -147,6 +149,7 @@ function createSettingsStore() {
confirmDestructive: settings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: settings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: settings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
showGitCommitHash: settings.showGitCommitHash ?? DEFAULT_SETTINGS.showGitCommitHash,
timeFormat: settings.timeFormat ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: settings.dateFormat ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: settings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
@@ -205,6 +208,7 @@ function createSettingsStore() {
confirmDestructive: updatedSettings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: updatedSettings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: updatedSettings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
showGitCommitHash: updatedSettings.showGitCommitHash ?? DEFAULT_SETTINGS.showGitCommitHash,
timeFormat: updatedSettings.timeFormat ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: updatedSettings.dateFormat ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: updatedSettings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
@@ -287,6 +291,13 @@ function createSettingsStore() {
return newSettings;
});
},
setShowGitCommitHash: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showGitCommitHash: value };
saveSettings({ showGitCommitHash: value });
return newSettings;
});
},
setTimeFormat: (value: TimeFormat) => {
update((current) => {
const newSettings = { ...current, timeFormat: value };
@@ -626,6 +637,15 @@ export function formatDate(date: Date | string | number): string {
return formatDatePart(d);
}
/**
* Compact relative time like "just now", "5m ago", "2d ago", "3mo ago".
* For display ONLY, alongside the absolute date - sort still uses the raw
* timestamp, never this string.
*/
// Re-exported from the import-light utils/format module (so it stays unit-testable
// without pulling $app/environment). Kept exported here for existing callers.
export { formatRelativeTime } from '$lib/utils/format';
/**
* Get the current time format setting (for components that need it).
*/
@@ -640,6 +660,14 @@ export function getDateFormat(): DateFormat {
return cachedDateFormat;
}
/**
* Get the global default timezone setting (for components that format a time and have
* no more-specific timezone to use). Falls back to UTC via DEFAULT_SETTINGS.
*/
export function getDefaultTimezone(): string {
return cachedDefaultTimezone;
}
// Regex matching ISO 8601 timestamps at the start of log lines (after optional container prefix)
// Matches: 2026-01-12T07:47:44.449821093Z or 2026-01-12T07:47:44Z
const ISO_TIMESTAMP_RE = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z/g;
+15
View File
@@ -0,0 +1,15 @@
import { Github, Gitlab } from 'lucide-svelte';
import type { Component } from 'svelte';
import GitGenericIcon from '$lib/components/icons/GitGenericIcon.svelte';
/**
* Pick a forge icon from a repo URL: GitHub and GitLab get their own mark,
* everything else gets the generic Git logo (mdi-git, not lucide's GitBranch
* which reads as a branch).
*/
export function forgeIcon(url: string | null | undefined): Component {
const u = (url || '').toLowerCase();
if (u.includes('github.com')) return Github as unknown as Component;
if (u.includes('gitlab.')) return Gitlab as unknown as Component;
return GitGenericIcon as unknown as Component;
}
+13 -1
View File
@@ -88,6 +88,8 @@ export interface GeneralSettings {
compactPorts: boolean;
// Show exposed (internal) ports
showExposedPorts: boolean;
// Show the deployed git commit hash in the stack source badge
showGitCommitHash: boolean;
// Log timestamp formatting
formatLogTimestamps: boolean;
// External stack paths
@@ -141,6 +143,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
metricsCollectionInterval: 30000,
compactPorts: false,
showExposedPorts: false,
showGitCommitHash: false,
formatLogTimestamps: false,
lightTheme: 'default',
darkTheme: 'default',
@@ -253,6 +256,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
editorFont,
compactPorts,
showExposedPorts,
showGitCommitHash,
formatLogTimestamps,
externalStackPaths,
primaryStackLocation,
@@ -302,6 +306,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('show_exposed_ports'),
getSetting('show_git_commit_hash'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation(),
@@ -354,6 +359,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
showGitCommitHash: showGitCommitHash ?? DEFAULT_SETTINGS.showGitCommitHash,
showExposedPorts: showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts,
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths,
@@ -387,7 +393,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -498,6 +504,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
await setSetting('theme_editor_font', editorFont);
}
if (showGitCommitHash !== undefined) {
await setSetting('show_git_commit_hash', showGitCommitHash);
}
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
@@ -601,6 +610,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
editorFontVal,
compactPortsVal,
showExposedPortsVal,
showGitCommitHashVal,
formatLogTimestampsVal,
externalStackPathsVal,
primaryStackLocationVal,
@@ -650,6 +660,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('show_exposed_ports'),
getSetting('show_git_commit_hash'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation(),
@@ -703,6 +714,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
showExposedPorts: showExposedPortsVal ?? DEFAULT_SETTINGS.showExposedPorts,
showGitCommitHash: showGitCommitHashVal ?? DEFAULT_SETTINGS.showGitCommitHash,
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal,
@@ -24,6 +24,7 @@
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let showExposedPorts = $derived($appSettings.showExposedPorts);
let showGitCommitHash = $derived($appSettings.showGitCommitHash);
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
let showWhatsNew = $derived($appSettings.showWhatsNew);
@@ -411,6 +412,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show git commit hash</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
<p>Shows the deployed short commit hash on the Git badge in the stacks list source column, with the full hash, repo URL and branch in a tooltip.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={showGitCommitHash}
onchange={(checked) => {
appSettings.setShowGitCommitHash(checked);
toast.success(checked ? 'Showing git commit hash on stack badges' : 'Git commit hash hidden');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Display the deployed commit hash on the Git source badge in the stacks list</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Honor Traefik/Pangolin labels</Label>
@@ -4,7 +4,8 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Plus, Trash2, Pencil, GitBranch, FolderGit2, Plug, CheckCircle, XCircle, Loader2, Github, Lock, Globe } from 'lucide-svelte';
import { Plus, Trash2, Pencil, GitBranch, FolderGit2, Plug, CheckCircle, XCircle, Loader2, Lock, Globe } from 'lucide-svelte';
import { forgeIcon } from '$lib/utils/git-forge';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import GitRepositoryModal from './GitRepositoryModal.svelte';
@@ -160,13 +161,10 @@
{:else}
<div class="space-y-1">
{#each repositories as repo (repo.id)}
{@const ForgeIcon = forgeIcon(repo.url)}
<div class="flex items-center justify-between py-2 px-3 rounded-md border bg-card hover:bg-muted/50 transition-colors">
<div class="flex items-center gap-2 min-w-0 flex-1">
{#if repo.url.includes('github.com')}
<Github class="w-4 h-4 shrink-0 text-muted-foreground" />
{:else}
<FolderGit2 class="w-4 h-4 shrink-0 text-muted-foreground" />
{/if}
<ForgeIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
<span class="font-medium text-sm truncate">{repo.name}</span>
<span class="text-xs text-muted-foreground truncate hidden sm:inline">{repo.url}</span>
</div>
+92 -63
View File
@@ -25,6 +25,7 @@
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
import StackModal from './StackModal.svelte';
import GitSourceBadge from './GitSourceBadge.svelte';
import GitStackModal from './GitStackModal.svelte';
import ImportStackModal from './ImportStackModal.svelte';
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
@@ -70,6 +71,7 @@
let showImportModal = $state(false);
let editingStackName = $state('');
let stackModalReadonly = $state(false);
let stackModalGitInfo = $state<{ commit?: string; url?: string; branch?: string } | null>(null);
let editingGitStack = $state<any>(null);
let envId = $state<number | null>(null);
@@ -492,7 +494,15 @@
let confirmRemoveContainerId = $state<string | null>(null);
// Operation error state (for stack and container operations)
let operationError = $state<{ id: string; title: string; message: string } | null>(null);
let operationError = $state<{ id: string; message: string } | null>(null);
// Inline operation-error auto-dismiss timers, cleared on destroy.
let pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
function clearErrorAfterDelay(id: string) {
pendingTimeouts.push(setTimeout(() => {
if (operationError?.id === id) operationError = null;
}, 5000));
}
// Error dialog state (for showing detailed errors)
let errorDialogData = $state<{ title: string; message: string } | null>(null);
@@ -1084,6 +1094,12 @@
function viewGitStack(name: string) {
editingStackName = name;
stackModalReadonly = true;
const src = getStackSource(name);
stackModalGitInfo = {
commit: src?.gitStack?.lastCommit || undefined,
url: src?.repository?.url || undefined,
branch: src?.repository?.branch || undefined
};
showEditModal = true;
}
@@ -1369,6 +1385,10 @@
// Cleanup on component destroy
onDestroy(() => {
// Clear pending inline-error dismiss timers
pendingTimeouts.forEach(clearTimeout);
pendingTimeouts = [];
// Clear polling intervals
if (stacksInterval) {
clearInterval(stacksInterval);
@@ -1684,7 +1704,7 @@
{stackEnvVarCounts[stack.name]}
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
<Tooltip.Content>
{stackEnvVarCounts[stack.name]} environment variable{stackEnvVarCounts[stack.name] !== 1 ? 's' : ''} configured
</Tooltip.Content>
</Tooltip.Root>
@@ -1737,7 +1757,7 @@
<span class="text-2xs font-medium text-amber-500 leading-none">{stack.updateCount}</span>
{/if}
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
<Tooltip.Content>
External stack - update possible for individual containers only.
</Tooltip.Content>
</Tooltip.Root>
@@ -1746,42 +1766,19 @@
</span>
{:else if column.id === 'source'}
{#if source.sourceType === 'git'}
{#if source.gitStack?.lastCommit}
<Tooltip.Root>
<Tooltip.Trigger>
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm min-w-[5.5rem]"
>
<GitBranch class="w-3 h-3" />
<span>Git</span>
<span class="font-mono text-[10px] opacity-75">{source.gitStack.lastCommit}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
<code class="text-xs">{source.gitStack.lastCommit}</code>
{#if source.repository}
<br />
<span class="text-xs">{source.repository.url} ({source.repository.branch})</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm min-w-[5.5rem]"
title={source.repository ? `${source.repository.url} (${source.repository.branch})` : 'Deployed from Git repository'}
>
<GitBranch class="w-3 h-3" />
Git
</span>
{/if}
<GitSourceBadge {source} />
{:else if source.sourceType === 'internal'}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm min-w-[5.5rem]"
title="Managed by Dockhand"
>
<FileCode class="w-3 h-3" />
Internal
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm min-w-[5.5rem]"
>
<FileCode class="w-3 h-3" />
Internal
</span>
</Tooltip.Trigger>
<Tooltip.Content>Managed by Dockhand</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
@@ -1792,7 +1789,7 @@
Untracked
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
<Tooltip.Content>
Compose file location unknown. Click the stack name or edit button to locate it.
</Tooltip.Content>
</Tooltip.Root>
@@ -1816,40 +1813,70 @@
{:else if column.id === 'containers'}
<div class="flex items-center gap-1">
{#if getContainerStateCounts(stack).running}
<span class="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400" title="Running">
<Play class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).running}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400">
<Play class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).running}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Running</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getContainerStateCounts(stack).exited}
<span class="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400" title="Exited">
<Square class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).exited}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400">
<Square class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).exited}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Exited</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getContainerStateCounts(stack).paused}
<span class="inline-flex items-center gap-0.5 text-amber-600 dark:text-amber-400" title="Paused">
<Pause class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).paused}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-amber-600 dark:text-amber-400">
<Pause class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).paused}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Paused</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getContainerStateCounts(stack).restarting}
<span class="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400" title="Restarting">
<span class="w-3.5 h-3.5 flex items-center justify-center"><RefreshCw class="w-3.5 h-3.5 animate-spin" /></span>
<span class="text-xs font-medium">{getContainerStateCounts(stack).restarting}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400">
<span class="w-3.5 h-3.5 flex items-center justify-center"><RefreshCw class="w-3.5 h-3.5 animate-spin" /></span>
<span class="text-xs font-medium">{getContainerStateCounts(stack).restarting}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Restarting</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getContainerStateCounts(stack).created}
<span class="inline-flex items-center gap-0.5 text-slate-500 dark:text-slate-400" title="Created">
<CircleDashed class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).created}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-slate-500 dark:text-slate-400">
<CircleDashed class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).created}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Created</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getContainerStateCounts(stack).dead}
<span class="inline-flex items-center gap-0.5 text-rose-700 dark:text-rose-400" title="Dead">
<Skull class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).dead}</span>
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 text-rose-700 dark:text-rose-400">
<Skull class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).dead}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Dead</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if stack.containers.length === 0}
<span class="text-xs text-muted-foreground">-</span>
@@ -2606,10 +2633,12 @@
mode="edit"
stackName={editingStackName}
readonly={stackModalReadonly}
gitInfo={stackModalGitInfo}
onClose={() => {
showEditModal = false;
editingStackName = '';
stackModalReadonly = false;
stackModalGitInfo = null;
}}
onSuccess={fetchStacks}
/>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { GitBranch, GitCommitHorizontal } from 'lucide-svelte';
import { forgeIcon } from '$lib/utils/git-forge';
import { appSettings } from '$lib/stores/settings';
interface Props {
source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null } | null };
}
let { source }: Props = $props();
const ForgeIcon = $derived(forgeIcon(source.repository?.url));
const showHash = $derived(!!source.gitStack?.lastCommit && $appSettings.showGitCommitHash);
// A tooltip is only worth showing when there's real git info in it.
const hasGitInfo = $derived(!!source.gitStack?.lastCommit || !!source.repository);
</script>
{#snippet badge()}
<span
class="inline-flex max-w-full items-center justify-center gap-1 overflow-hidden text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm {showHash ? '' : 'min-w-[5.5rem]'}"
>
<ForgeIcon class="w-3 h-3 shrink-0" />
<span class="shrink-0">Git</span>
{#if showHash}
<span class="font-mono text-[10px] opacity-75 truncate">{source.gitStack!.lastCommit}</span>
{/if}
</span>
{/snippet}
{#if hasGitInfo}
<Tooltip.Root>
<Tooltip.Trigger class="block max-w-full overflow-hidden">
{@render badge()}
</Tooltip.Trigger>
<Tooltip.Content class="p-0">
<div class="flex flex-col gap-1.5 px-3 py-2">
{#if source.gitStack?.lastCommit}
<div class="flex items-center gap-2">
<GitCommitHorizontal class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<code class="text-xs">{source.gitStack.lastCommit}</code>
</div>
{/if}
{#if source.repository}
{#if source.repository.branch}
<div class="flex items-center gap-2">
<GitBranch class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span class="text-xs">{source.repository.branch}</span>
</div>
{/if}
<div class="flex items-center gap-2">
<ForgeIcon class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span class="text-xs">{source.repository.url}</span>
</div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render badge()}
{/if}
+35 -6
View File
@@ -7,7 +7,7 @@
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { Layers, Save, Play, Code, GitGraph, GitBranch, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
import BackupPanel from '../containers/BackupPanel.svelte';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
@@ -27,10 +27,7 @@
import { readJobResponse } from '$lib/utils/sse-fetch';
import { toast } from 'svelte-sonner';
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
// localStorage key for persisted split ratio
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
@@ -42,11 +39,15 @@
initialCompose?: string; // Pre-fill compose content (for library deploy)
initialStackName?: string; // Pre-fill stack name (for library deploy)
readonly?: boolean; // View compose content without allowing local changes
gitInfo?: { commit?: string; url?: string; branch?: string } | null; // Git provenance for read-only git stacks
onClose: () => void;
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, readonly = false, onClose, onSuccess }: Props = $props();
let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, readonly = false, gitInfo = null, onClose, onSuccess }: Props = $props();
let gitCommitCopied = $state<'ok' | 'error' | null>(null);
let gitUrlCopied = $state<'ok' | 'error' | null>(null);
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
@@ -1388,7 +1389,7 @@
}}
>
<Dialog.Content
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
class="max-w-none w-[calc(100vw-4rem)] h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
@@ -1540,6 +1541,34 @@
<span class="ml-4 flex shrink-0 items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/10 px-2 py-0.5 text-2xs font-medium text-purple-600 dark:text-purple-400" title="This is a Git-managed stack — Dockhand shows its compose read-only; edit it in the repository.">
<GitBranch class="h-3 w-3" /> Git · read-only
</span>
{#if gitInfo && (gitInfo.commit || gitInfo.url || gitInfo.branch)}
<div class="ml-3 flex min-w-0 items-center gap-3 text-2xs text-muted-foreground">
{#if gitInfo.commit}
<span class="flex shrink-0 items-center gap-1">
<GitCommitHorizontal class="h-3.5 w-3.5 shrink-0 opacity-70" />
<code class="font-mono">{gitInfo.commit}</code>
<button type="button" class="rounded p-0.5 hover:bg-muted transition-colors" title="Copy commit hash" onclick={() => copyText(gitInfo.commit ?? null, (v) => gitCommitCopied = v)}>
{#if gitCommitCopied === 'ok'}<Check class="h-3 w-3 text-green-500" />{:else}<Copy class="h-3 w-3" />{/if}
</button>
</span>
{/if}
{#if gitInfo.url}
<span class="flex min-w-0 items-center gap-1">
<Github class="h-3.5 w-3.5 shrink-0 opacity-70" />
<span class="truncate">{gitInfo.url}</span>
<button type="button" class="rounded p-0.5 hover:bg-muted transition-colors shrink-0" title="Copy repository URL" onclick={() => copyText(gitInfo.url ?? null, (v) => gitUrlCopied = v)}>
{#if gitUrlCopied === 'ok'}<Check class="h-3 w-3 text-green-500" />{:else}<Copy class="h-3 w-3" />{/if}
</button>
</span>
{/if}
{#if gitInfo.branch}
<span class="flex shrink-0 items-center gap-1">
<GitBranch class="h-3.5 w-3.5 shrink-0 opacity-70" />
<span>{gitInfo.branch}</span>
</span>
{/if}
</div>
{/if}
{/if}
<!-- Compose path -->
<div class="flex-shrink-0 px-4 py-2" style="width: {splitRatio}%">