1.0.18
This commit is contained in:
1 parent
d83ca684d7
commit
8ee4fe4d68
49 files changed
+2126
-261
No files matched your search
@@ -2,14 +2,16 @@
|
||||
// Trigger rebuild for debug logging changes
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database, CircleArrowUp, Loader2, CheckCircle2 } from 'lucide-svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { browser } from '$app/environment';
|
||||
import LicenseModal from './LicenseModal.svelte';
|
||||
import PrivacyModal from './PrivacyModal.svelte';
|
||||
import SelfUpdateDialog from './SelfUpdateDialog.svelte';
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
@@ -253,6 +255,65 @@
|
||||
let showLicenseModal = $state(false);
|
||||
let showPrivacyModal = $state(false);
|
||||
|
||||
// Self-update state
|
||||
let checkingUpdate = $state(false);
|
||||
let updateCheckDone = $state(false);
|
||||
let updateAvailable = $state(false);
|
||||
let updateInfo = $state<{
|
||||
currentImage: string;
|
||||
newImage: string;
|
||||
currentDigest: string;
|
||||
newDigest: string;
|
||||
containerName: string;
|
||||
isComposeManaged: boolean;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
let updateCheckError = $state<string | null>(null);
|
||||
let showSelfUpdateDialog = $state(false);
|
||||
|
||||
async function checkForUpdates() {
|
||||
checkingUpdate = true;
|
||||
updateCheckDone = false;
|
||||
updateAvailable = false;
|
||||
updateInfo = null;
|
||||
updateCheckError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/self-update/check');
|
||||
if (!response.ok) {
|
||||
updateCheckError = 'Failed to check for updates';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error && !data.updateAvailable) {
|
||||
// Not in Docker or other non-critical issue
|
||||
updateCheckError = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
updateAvailable = data.updateAvailable;
|
||||
updateCheckDone = true;
|
||||
|
||||
if (data.updateAvailable) {
|
||||
updateInfo = {
|
||||
currentImage: data.currentImage,
|
||||
newImage: data.currentImage,
|
||||
currentDigest: data.currentDigest || '',
|
||||
newDigest: data.newDigest || '',
|
||||
containerName: data.containerName,
|
||||
isComposeManaged: data.isComposeManaged
|
||||
};
|
||||
showSelfUpdateDialog = true;
|
||||
}
|
||||
} catch (err) {
|
||||
updateCheckError = 'Check failed: ' + String(err);
|
||||
} finally {
|
||||
checkingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
@@ -364,6 +425,7 @@
|
||||
fetchSystemInfo();
|
||||
fetchDependencies();
|
||||
fetchChangelog();
|
||||
checkForUpdates();
|
||||
// Increment uptime every second for real-time display
|
||||
uptimeInterval = setInterval(() => {
|
||||
if (serverUptime !== null) {
|
||||
@@ -419,9 +481,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Version Badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Version Badge + Update Check -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" class="text-xs">Version {currentVersion}</Badge>
|
||||
{#if checkingUpdate}
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
Checking for updates...
|
||||
</span>
|
||||
{:else if updateAvailable && updateInfo}
|
||||
<button class="flex items-center gap-1 text-xs text-amber-500 hover:text-amber-400 transition-colors" onclick={() => showSelfUpdateDialog = true}>
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
Update available — click to see what's new
|
||||
</button>
|
||||
{:else if updateCheckDone && !updateAvailable}
|
||||
<button class="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 dark:hover:text-emerald-300 transition-colors" onclick={checkForUpdates}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5" />
|
||||
Up to date
|
||||
</button>
|
||||
{:else if updateCheckError}
|
||||
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" onclick={checkForUpdates} title={updateCheckError}>
|
||||
Check for updates
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Build & Uptime Info -->
|
||||
@@ -871,3 +953,16 @@
|
||||
|
||||
<LicenseModal bind:open={showLicenseModal} />
|
||||
<PrivacyModal bind:open={showPrivacyModal} />
|
||||
|
||||
{#if updateInfo}
|
||||
<SelfUpdateDialog
|
||||
bind:open={showSelfUpdateDialog}
|
||||
currentImage={updateInfo.currentImage}
|
||||
newImage={updateInfo.newImage}
|
||||
currentDigest={updateInfo.currentDigest}
|
||||
newDigest={updateInfo.newDigest}
|
||||
containerName={updateInfo.containerName}
|
||||
isComposeManaged={updateInfo.isComposeManaged}
|
||||
onclose={() => showSelfUpdateDialog = false}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,654 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { CircleArrowUp, CheckCircle2, XCircle, Loader2, Circle, Ship, Sparkles, Bug, Wrench, RotateCcw, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
declare const __APP_VERSION__: string | null;
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: Array<{ type: string; text: string }>;
|
||||
imageTag?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
currentImage: string;
|
||||
newImage: string;
|
||||
currentDigest?: string;
|
||||
newDigest?: string;
|
||||
containerName: string;
|
||||
isComposeManaged?: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), currentImage, newImage, currentDigest = '', newDigest = '', containerName, isComposeManaged = false, onclose }: Props = $props();
|
||||
|
||||
// Phase management
|
||||
type Phase = 'confirm' | 'preparing' | 'updating' | 'restarting' | 'completed' | 'error';
|
||||
let phase = $state<Phase>('confirm');
|
||||
let updaterId = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let pollTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let healthTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let consecutivePollFailures = $state(0);
|
||||
|
||||
// Release notes
|
||||
let releaseNotes = $state<ChangelogEntry[]>([]);
|
||||
let loadingNotes = $state(false);
|
||||
|
||||
const currentVersion = __APP_VERSION__?.replace(/^v/, '') || '';
|
||||
|
||||
// All steps in a single unified list
|
||||
interface StepState {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'active' | 'completed' | 'error';
|
||||
logs: string[];
|
||||
showLogs: boolean;
|
||||
}
|
||||
|
||||
const ALL_STEPS = [
|
||||
// Preparation (from SSE)
|
||||
{ id: 'pulling_image', label: 'Pulling new image' },
|
||||
{ id: 'building_config', label: 'Building container config' },
|
||||
{ id: 'pulling_updater', label: 'Pulling updater' },
|
||||
{ id: 'creating_container', label: 'Creating new container' },
|
||||
{ id: 'launching_updater', label: 'Launching updater' },
|
||||
// Update (from updater container logs)
|
||||
{ id: 'stopping', label: 'Stopping Dockhand' },
|
||||
{ id: 'removing', label: 'Removing old container' },
|
||||
{ id: 'renaming', label: 'Renaming container' },
|
||||
{ id: 'connecting', label: 'Connecting networks' },
|
||||
{ id: 'starting', label: 'Starting Dockhand' },
|
||||
// Reconnect
|
||||
{ id: 'reconnecting', label: 'Waiting for Dockhand' }
|
||||
] as const;
|
||||
|
||||
// Updater log markers → step id mapping
|
||||
const UPDATER_STEP_MARKERS: { start: string; end: string; id: string }[] = [
|
||||
{ start: 'Stopping container', end: 'Container stopped', id: 'stopping' },
|
||||
{ start: 'Removing old container', end: 'Old container removed', id: 'removing' },
|
||||
{ start: 'Renaming container', end: 'Container renamed', id: 'renaming' },
|
||||
{ start: 'Connecting to network', end: 'Networks connected', id: 'connecting' },
|
||||
{ start: 'Starting container', end: 'Container is running', id: 'starting' }
|
||||
];
|
||||
|
||||
let steps = $state<StepState[]>(ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false })));
|
||||
let scrollTick = $state(0);
|
||||
let stepsListEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Auto-scroll steps list
|
||||
$effect(() => {
|
||||
scrollTick;
|
||||
if (stepsListEl) {
|
||||
requestAnimationFrame(() => {
|
||||
stepsListEl?.scrollTo({ top: stepsListEl.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch release notes when dialog opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
fetchReleaseNotes();
|
||||
} else {
|
||||
if (phase === 'confirm' || phase === 'completed' || phase === 'error') {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
phase = 'confirm';
|
||||
updaterId = null;
|
||||
errorMessage = null;
|
||||
consecutivePollFailures = 0;
|
||||
releaseNotes = [];
|
||||
lastParsedLogCount = 0;
|
||||
steps = ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false }));
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
if (healthTimer) { clearTimeout(healthTimer); healthTimer = null; }
|
||||
}
|
||||
|
||||
function getStep(id: string): StepState | undefined {
|
||||
return steps.find(s => s.id === id);
|
||||
}
|
||||
|
||||
function setStepStatus(id: string, status: StepState['status']) {
|
||||
const step = getStep(id);
|
||||
if (step) {
|
||||
step.status = status;
|
||||
steps = [...steps];
|
||||
scrollTick++;
|
||||
}
|
||||
}
|
||||
|
||||
function addStepLog(id: string, message: string) {
|
||||
const step = getStep(id);
|
||||
if (step) {
|
||||
step.logs = [...step.logs, message];
|
||||
step.showLogs = true;
|
||||
steps = [...steps];
|
||||
scrollTick++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the currently active step id */
|
||||
function activeStepId(): string | null {
|
||||
const active = steps.find(s => s.status === 'active');
|
||||
return active?.id ?? null;
|
||||
}
|
||||
|
||||
async function fetchReleaseNotes() {
|
||||
loadingNotes = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://raw.githubusercontent.com/Finsys/dockhand/main/src/lib/data/changelog.json',
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const changelog: ChangelogEntry[] = await response.json();
|
||||
if (currentVersion && changelog.length > 0) {
|
||||
const newer = changelog.filter(entry => compareVersions(entry.version, currentVersion) > 0);
|
||||
releaseNotes = newer.length > 0 ? newer : changelog.slice(0, 1);
|
||||
} else if (changelog.length > 0) {
|
||||
releaseNotes = changelog.slice(0, 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
loadingNotes = false;
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const va = pa[i] || 0;
|
||||
const vb = pb[i] || 0;
|
||||
if (va > vb) return 1;
|
||||
if (va < vb) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getChangeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'feature': return Sparkles;
|
||||
case 'fix': return Bug;
|
||||
case 'improvement': return Wrench;
|
||||
default: return Wrench;
|
||||
}
|
||||
}
|
||||
|
||||
function getChangeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'feature': return 'text-emerald-500';
|
||||
case 'fix': return 'text-amber-500';
|
||||
case 'improvement': return 'text-blue-500';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update Flow ---
|
||||
|
||||
async function startUpdate() {
|
||||
phase = 'preparing';
|
||||
errorMessage = null;
|
||||
steps = ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/self-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ newImage })
|
||||
});
|
||||
|
||||
// Check for JSON error response (fail-fast validation)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
phase = 'error';
|
||||
errorMessage = data.error || 'Update failed';
|
||||
return;
|
||||
}
|
||||
|
||||
// It's an SSE stream
|
||||
if (!response.body) {
|
||||
phase = 'error';
|
||||
errorMessage = 'No response body';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let eventType = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
handleSSEEvent(eventType, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (phase === 'preparing') {
|
||||
phase = 'error';
|
||||
errorMessage = 'Connection lost: ' + String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSEEvent(event: string, data: any) {
|
||||
if (event === 'step') {
|
||||
const stepId = data.step;
|
||||
if (data.status === 'completed') {
|
||||
setStepStatus(stepId, 'completed');
|
||||
} else {
|
||||
setStepStatus(stepId, 'active');
|
||||
}
|
||||
} else if (event === 'log') {
|
||||
// Attach log to the currently active step
|
||||
const currentId = activeStepId();
|
||||
if (currentId) {
|
||||
addStepLog(currentId, data.message);
|
||||
}
|
||||
} else if (event === 'launched') {
|
||||
updaterId = data.updaterId;
|
||||
phase = 'updating';
|
||||
consecutivePollFailures = 0;
|
||||
startProgressPolling();
|
||||
} else if (event === 'error') {
|
||||
phase = 'error';
|
||||
errorMessage = data.message || 'Update failed';
|
||||
// Mark current active step as error
|
||||
const currentId = activeStepId();
|
||||
if (currentId) {
|
||||
setStepStatus(currentId, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
pollProgress();
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
if (!updaterId || phase === 'restarting' || phase === 'completed' || phase === 'error') return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/self-update/progress?id=${updaterId}`, {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json() as {
|
||||
logs: string;
|
||||
status: 'running' | 'exited' | 'removed';
|
||||
exitCode?: number;
|
||||
};
|
||||
|
||||
consecutivePollFailures = 0;
|
||||
parseLogsIntoSteps(data.logs);
|
||||
|
||||
if (data.status === 'removed') {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'exited') {
|
||||
if (data.exitCode === 0) {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
} else {
|
||||
phase = 'error';
|
||||
errorMessage = `Updater exited with code ${data.exitCode}`;
|
||||
const currentId = activeStepId();
|
||||
if (currentId) setStepStatus(currentId, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(pollProgress, 1500);
|
||||
} catch {
|
||||
consecutivePollFailures++;
|
||||
if (consecutivePollFailures >= 3) {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
} else {
|
||||
pollTimer = setTimeout(pollProgress, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastParsedLogCount = 0;
|
||||
|
||||
function parseLogsIntoSteps(logText: string) {
|
||||
if (!logText) return;
|
||||
|
||||
const rawLines = logText.split('\n').filter(l => l.trim());
|
||||
|
||||
// Add new log lines to the appropriate step
|
||||
if (rawLines.length > lastParsedLogCount) {
|
||||
for (let i = lastParsedLogCount; i < rawLines.length; i++) {
|
||||
const line = rawLines[i];
|
||||
const cleanLine = line.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\s*/, '');
|
||||
|
||||
// Find which step this log belongs to
|
||||
let stepId = activeStepId();
|
||||
for (const marker of UPDATER_STEP_MARKERS) {
|
||||
if (cleanLine.includes(marker.start)) {
|
||||
stepId = marker.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stepId) {
|
||||
addStepLog(stepId, line);
|
||||
}
|
||||
}
|
||||
lastParsedLogCount = rawLines.length;
|
||||
}
|
||||
|
||||
// Update step statuses based on full log
|
||||
let currentStepIdx = -1;
|
||||
for (const line of rawLines) {
|
||||
const cleanLine = line.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\s*/, '');
|
||||
|
||||
for (let i = 0; i < UPDATER_STEP_MARKERS.length; i++) {
|
||||
const marker = UPDATER_STEP_MARKERS[i];
|
||||
if (cleanLine.includes(marker.start)) {
|
||||
currentStepIdx = i;
|
||||
setStepStatus(marker.id, 'active');
|
||||
// Complete all prior updater steps
|
||||
for (let j = 0; j < i; j++) {
|
||||
setStepStatus(UPDATER_STEP_MARKERS[j].id, 'completed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStepIdx >= 0) {
|
||||
const marker = UPDATER_STEP_MARKERS[currentStepIdx];
|
||||
if (cleanLine.includes(marker.end)) {
|
||||
setStepStatus(marker.id, 'completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function markAllUpdateStepsComplete() {
|
||||
for (const marker of UPDATER_STEP_MARKERS) {
|
||||
setStepStatus(marker.id, 'completed');
|
||||
}
|
||||
}
|
||||
|
||||
function switchToRestarting() {
|
||||
phase = 'restarting';
|
||||
stopPolling();
|
||||
setStepStatus('reconnecting', 'active');
|
||||
startHealthPolling();
|
||||
}
|
||||
|
||||
function startHealthPolling() {
|
||||
pollHealth();
|
||||
}
|
||||
|
||||
async function pollHealth() {
|
||||
if (phase !== 'restarting') return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/health', { signal: AbortSignal.timeout(2000) });
|
||||
if (response.ok) {
|
||||
phase = 'completed';
|
||||
const step = getStep('reconnecting');
|
||||
if (step) {
|
||||
step.label = 'Dockhand is back online';
|
||||
step.status = 'completed';
|
||||
steps = [...steps];
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Still down
|
||||
}
|
||||
|
||||
healthTimer = setTimeout(pollHealth, 3000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (phase === 'preparing' || phase === 'updating' || phase === 'restarting') {
|
||||
return;
|
||||
}
|
||||
stopPolling();
|
||||
resetState();
|
||||
onclose();
|
||||
}
|
||||
|
||||
function getIconComponent(status: string) {
|
||||
switch (status) {
|
||||
case 'completed': return CheckCircle2;
|
||||
case 'active': return Loader2;
|
||||
case 'error': return XCircle;
|
||||
default: return Circle;
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-600 dark:text-green-400';
|
||||
case 'active': return 'text-blue-600 dark:text-blue-400 animate-spin';
|
||||
case 'error': return 'text-red-600 dark:text-red-400';
|
||||
default: return 'text-muted-foreground/30';
|
||||
}
|
||||
}
|
||||
|
||||
const canClose = $derived(phase === 'confirm' || phase === 'completed' || phase === 'error');
|
||||
const visibleSteps = $derived(steps.filter(s => s.status !== 'pending'));
|
||||
|
||||
const activeStep = $derived(steps.find(s => s.status === 'active'));
|
||||
const completedCount = $derived(steps.filter(s => s.status === 'completed').length);
|
||||
const progressPercentage = $derived(Math.round((completedCount / ALL_STEPS.length) * 100));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] overflow-hidden flex flex-col" onInteractOutside={(e) => { if (!canClose) e.preventDefault(); }}>
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CircleArrowUp class="w-5 h-5 text-amber-500" />
|
||||
{#if phase === 'confirm'}
|
||||
Update Dockhand
|
||||
{:else}
|
||||
Updating Dockhand
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
{#if phase !== 'confirm'}
|
||||
<Dialog.Description>
|
||||
{#if activeStep}
|
||||
<span class="text-primary font-medium">{activeStep.label}...</span>
|
||||
<span class="text-muted-foreground ml-2">({completedCount}/{ALL_STEPS.length})</span>
|
||||
{:else if phase === 'completed'}
|
||||
Update complete
|
||||
{:else if phase === 'error'}
|
||||
Update failed
|
||||
{:else}
|
||||
Preparing...
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
{#if phase === 'confirm'}
|
||||
<!-- Confirmation View -->
|
||||
<div class="space-y-4 py-2 overflow-y-auto min-h-0 flex-1">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Container</span>
|
||||
<span class="font-medium flex items-center gap-1.5">
|
||||
<Ship class="w-3.5 h-3.5" />
|
||||
{containerName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Image</span>
|
||||
<Badge variant="secondary" class="font-mono text-xs">{currentImage}</Badge>
|
||||
</div>
|
||||
{#if currentDigest || newDigest}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Current digest</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{currentDigest ? currentDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">New digest</span>
|
||||
<span class="font-mono text-xs text-amber-500">{newDigest ? newDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingNotes}
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
Loading release notes...
|
||||
</div>
|
||||
{:else if releaseNotes.length > 0}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<div class="bg-muted/50 px-3 py-2 border-b">
|
||||
<p class="text-sm font-medium">What's new</p>
|
||||
</div>
|
||||
<div class="p-3 space-y-3 max-h-48 overflow-y-auto">
|
||||
{#each releaseNotes as entry}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="text-2xs font-mono">v{entry.version}</Badge>
|
||||
<span class="text-2xs text-muted-foreground">{entry.date}</span>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each entry.changes as change}
|
||||
{@const ChangeIcon = getChangeIcon(change.type)}
|
||||
<li class="flex items-start gap-1.5 text-xs">
|
||||
<ChangeIcon class="w-3 h-3 mt-0.5 shrink-0 {getChangeColor(change.type)}" />
|
||||
<span class="text-muted-foreground">{change.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isComposeManaged}
|
||||
<div class="rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span class="font-medium text-blue-400">Note:</span> This container is managed by Docker Compose. After update it will continue to work but may lose Compose tracking. Use <code class="text-2xs">docker compose pull && docker compose up -d</code> for Compose-aware updates.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={startUpdate}>
|
||||
<CircleArrowUp class="w-4 h-4 mr-2" />
|
||||
Update now
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
{:else}
|
||||
<!-- Progress View -->
|
||||
<div class="flex-1 min-h-0 space-y-4 py-4 overflow-hidden flex flex-col">
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-2 shrink-0">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<Badge variant="secondary">{completedCount}/{ALL_STEPS.length}</Badge>
|
||||
</div>
|
||||
<Progress value={progressPercentage} class="h-2" />
|
||||
</div>
|
||||
|
||||
<!-- Steps list -->
|
||||
{#if visibleSteps.length > 0}
|
||||
<div bind:this={stepsListEl} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
|
||||
{#each visibleSteps as step (step.id)}
|
||||
{@const StepIcon = getIconComponent(step.status)}
|
||||
{@const hasLogs = step.logs.length > 0}
|
||||
<div class="text-sm">
|
||||
<!-- Step header -->
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<StepIcon class="w-4 h-4 shrink-0 {getIconClass(step.status)}" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium">{step.label}</div>
|
||||
</div>
|
||||
{#if step.status === 'completed'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
{:else if step.status === 'error'}
|
||||
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Logs (always visible) -->
|
||||
{#if hasLogs}
|
||||
<div class="bg-muted/50 px-3 py-2 font-mono text-xs border-t overflow-x-hidden">
|
||||
{#each step.logs as line}
|
||||
<div class="text-muted-foreground break-all">{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error message -->
|
||||
{#if phase === 'error' && errorMessage}
|
||||
<div class="flex items-start gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg overflow-hidden shrink-0">
|
||||
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span class="break-all">{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0">
|
||||
{#if phase === 'completed'}
|
||||
<Button onclick={() => window.location.reload()}>
|
||||
<RotateCcw class="w-4 h-4 mr-2" />
|
||||
Reload
|
||||
</Button>
|
||||
{:else if phase === 'error'}
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" disabled>
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Updating...
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -57,7 +57,8 @@
|
||||
X,
|
||||
Tags,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
@@ -70,6 +71,7 @@
|
||||
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
|
||||
import { ShieldOff } from 'lucide-svelte';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { authStore, canAccess } from '$lib/stores/auth';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { formatDateTime, formatDate } from '$lib/stores/settings';
|
||||
@@ -321,8 +323,8 @@
|
||||
let hawserTokenLoading = $state(false);
|
||||
let generatingToken = $state(false);
|
||||
let generatedToken = $state<string | null>(null); // Full token shown once after generation
|
||||
let copySuccess = $state(false);
|
||||
let copyCmdSuccess = $state(false);
|
||||
let copySuccess = $state<'ok' | 'error' | null>(null);
|
||||
let copyCmdSuccess = $state<'ok' | 'error' | null>(null);
|
||||
// For add mode - auto-generated token stored until save
|
||||
let pendingToken = $state<string | null>(null);
|
||||
|
||||
@@ -1268,17 +1270,17 @@
|
||||
await generateHawserToken(envId);
|
||||
}
|
||||
|
||||
function copyToken(token: string) {
|
||||
navigator.clipboard.writeText(token);
|
||||
copySuccess = true;
|
||||
setTimeout(() => { copySuccess = false; }, 2000);
|
||||
async function copyToken(token: string) {
|
||||
const ok = await copyToClipboard(token);
|
||||
copySuccess = ok ? 'ok' : 'error';
|
||||
setTimeout(() => { copySuccess = null; }, 2000);
|
||||
}
|
||||
|
||||
function copyCommand(token: string) {
|
||||
async function copyCommand(token: string) {
|
||||
const cmd = `DOCKHAND_SERVER_URL=${getConnectionUrl()} TOKEN=${token} hawser`;
|
||||
navigator.clipboard.writeText(cmd);
|
||||
copyCmdSuccess = true;
|
||||
setTimeout(() => { copyCmdSuccess = false; }, 2000);
|
||||
const ok = await copyToClipboard(cmd);
|
||||
copyCmdSuccess = ok ? 'ok' : 'error';
|
||||
setTimeout(() => { copyCmdSuccess = null; }, 2000);
|
||||
}
|
||||
|
||||
function getConnectionUrl() {
|
||||
@@ -1883,7 +1885,14 @@
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={() => copyToken(pendingToken!)}>
|
||||
{#if copySuccess}
|
||||
{#if copySuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copySuccess === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
@@ -1899,7 +1908,14 @@
|
||||
onclick={() => copyCommand(pendingToken!)}
|
||||
title="Copy command"
|
||||
>
|
||||
{#if copyCmdSuccess}
|
||||
{#if copyCmdSuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copyCmdSuccess === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
@@ -1936,7 +1952,14 @@
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={() => copyToken(generatedToken!)}>
|
||||
{#if copySuccess}
|
||||
{#if copySuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copySuccess === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
@@ -1952,7 +1975,14 @@
|
||||
onclick={() => copyCommand(generatedToken!)}
|
||||
title="Copy command"
|
||||
>
|
||||
{#if copyCmdSuccess}
|
||||
{#if copyCmdSuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copyCmdSuccess === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
|
||||
Reference in new issue
Block a user