diff --git a/src/lib/components/icons/GitGenericIcon.svelte b/src/lib/components/icons/GitGenericIcon.svelte new file mode 100644 index 0000000..c32c401 --- /dev/null +++ b/src/lib/components/icons/GitGenericIcon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index c460a3a..6ddf4e6 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -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; diff --git a/src/lib/utils/git-forge.ts b/src/lib/utils/git-forge.ts new file mode 100644 index 0000000..e65e9dc --- /dev/null +++ b/src/lib/utils/git-forge.ts @@ -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; +} diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 622f8c8..71cf52b 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -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 { 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, diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index 44033a7..0127972 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -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:

Display internal container ports in the container list grid

+
+
+ + + + + + +

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.

+
+
+ { + appSettings.setShowGitCommitHash(checked); + toast.success(checked ? 'Showing git commit hash on stack badges' : 'Git commit hash hidden'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
+

Display the deployed commit hash on the Git source badge in the stacks list

+
diff --git a/src/routes/settings/git/GitRepositoriesTab.svelte b/src/routes/settings/git/GitRepositoriesTab.svelte index dc8be2d..b9c6871 100644 --- a/src/routes/settings/git/GitRepositoriesTab.svelte +++ b/src/routes/settings/git/GitRepositoriesTab.svelte @@ -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}
{#each repositories as repo (repo.id)} + {@const ForgeIcon = forgeIcon(repo.url)}
- {#if repo.url.includes('github.com')} - - {:else} - - {/if} + {repo.name}
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 2e0828e..3626dfc 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -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(null); let envId = $state(null); @@ -492,7 +494,15 @@ let confirmRemoveContainerId = $state(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[] = []; + 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]} - + {stackEnvVarCounts[stack.name]} environment variable{stackEnvVarCounts[stack.name] !== 1 ? 's' : ''} configured @@ -1737,7 +1757,7 @@ {stack.updateCount} {/if} - + External stack - update possible for individual containers only. @@ -1746,42 +1766,19 @@ {:else if column.id === 'source'} {#if source.sourceType === 'git'} - {#if source.gitStack?.lastCommit} - - - - - Git - {source.gitStack.lastCommit} - - - - {source.gitStack.lastCommit} - {#if source.repository} -
- {source.repository.url} ({source.repository.branch}) - {/if} -
-
- {:else} - - - Git - - {/if} + {:else if source.sourceType === 'internal'} - - - Internal - + + + + + Internal + + + Managed by Dockhand + {:else} @@ -1792,7 +1789,7 @@ Untracked - + Compose file location unknown. Click the stack name or edit button to locate it. @@ -1816,40 +1813,70 @@ {:else if column.id === 'containers'}
{#if getContainerStateCounts(stack).running} - - - {getContainerStateCounts(stack).running} - + + + + + {getContainerStateCounts(stack).running} + + + Running + {/if} {#if getContainerStateCounts(stack).exited} - - - {getContainerStateCounts(stack).exited} - + + + + + {getContainerStateCounts(stack).exited} + + + Exited + {/if} {#if getContainerStateCounts(stack).paused} - - - {getContainerStateCounts(stack).paused} - + + + + + {getContainerStateCounts(stack).paused} + + + Paused + {/if} {#if getContainerStateCounts(stack).restarting} - - - {getContainerStateCounts(stack).restarting} - + + + + + {getContainerStateCounts(stack).restarting} + + + Restarting + {/if} {#if getContainerStateCounts(stack).created} - - - {getContainerStateCounts(stack).created} - + + + + + {getContainerStateCounts(stack).created} + + + Created + {/if} {#if getContainerStateCounts(stack).dead} - - - {getContainerStateCounts(stack).dead} - + + + + + {getContainerStateCounts(stack).dead} + + + Dead + {/if} {#if stack.containers.length === 0} - @@ -2606,10 +2633,12 @@ mode="edit" stackName={editingStackName} readonly={stackModalReadonly} + gitInfo={stackModalGitInfo} onClose={() => { showEditModal = false; editingStackName = ''; stackModalReadonly = false; + stackModalGitInfo = null; }} onSuccess={fetchStacks} /> diff --git a/src/routes/stacks/GitSourceBadge.svelte b/src/routes/stacks/GitSourceBadge.svelte new file mode 100644 index 0000000..3aead6f --- /dev/null +++ b/src/routes/stacks/GitSourceBadge.svelte @@ -0,0 +1,60 @@ + + +{#snippet badge()} + + + Git + {#if showHash} + {source.gitStack!.lastCommit} + {/if} + +{/snippet} + +{#if hasGitInfo} + + + {@render badge()} + + +
+ {#if source.gitStack?.lastCommit} +
+ + {source.gitStack.lastCommit} +
+ {/if} + {#if source.repository} + {#if source.repository.branch} +
+ + {source.repository.branch} +
+ {/if} +
+ + {source.repository.url} +
+ {/if} +
+
+
+{:else} + {@render badge()} +{/if} diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index eab2c2f..566f05d 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -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 @@ }} > @@ -1540,6 +1541,34 @@ Git · read-only + {#if gitInfo && (gitInfo.commit || gitInfo.url || gitInfo.branch)} +
+ {#if gitInfo.commit} + + + {gitInfo.commit} + + + {/if} + {#if gitInfo.url} + + + {gitInfo.url} + + + {/if} + {#if gitInfo.branch} + + + {gitInfo.branch} + + {/if} +
+ {/if} {/if}