fix: per-event delete button, Docker network default, polling interval duration parsing

- Add per-event delete button (trash icon on hover) in event log entries
- Set Docker network default to 'docker-watcher' in DB schema + migration for existing DBs
- Parse Go duration strings (5m, 1h) to seconds in settings UI, convert back on save
- Clear error when network is empty in deployer instead of hidden fallback
This commit is contained in:
2026-04-05 02:02:03 +03:00
parent c26c41e6a1
commit f71f2275a2
5 changed files with 53 additions and 5 deletions
+3
View File
@@ -286,6 +286,9 @@ func (d *Deployer) executeDeploy(
d.logDeploy(deployID, "Image pulled successfully", "info")
// Step 2: Ensure network exists.
if settings.Network == "" {
return containerID, proxyRouteID, instanceID, fmt.Errorf("docker network not configured in settings")
}
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err)
+3 -1
View File
@@ -109,6 +109,8 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'docker-watcher' WHERE network = ''`,
}
for _, m := range migrations {
@@ -198,7 +200,7 @@ CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
domain TEXT NOT NULL DEFAULT '',
server_ip TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT 'docker-watcher',
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
notification_url TEXT NOT NULL DEFAULT '',
npm_url TEXT NOT NULL DEFAULT '',
+13 -1
View File
@@ -5,13 +5,15 @@
<script lang="ts">
import type { EventLogEntry } from '$lib/types';
import { t } from '$lib/i18n';
import { IconTrash } from '$lib/components/icons';
interface Props {
entry: EventLogEntry;
isNew?: boolean;
ondelete?: (id: number) => void;
}
const { entry, isNew = false }: Props = $props();
const { entry, isNew = false, ondelete }: Props = $props();
let expanded = $state(false);
@@ -82,6 +84,16 @@
<span class="ml-auto shrink-0 text-[var(--text-tertiary)] tabular-nums" title={formatFull(entry.created_at)}>
{timeAgo(entry.created_at)}
</span>
{#if ondelete}
<button
type="button"
title={$t('common.delete')}
onclick={() => ondelete(entry.id)}
class="shrink-0 rounded p-0.5 text-[var(--text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-danger)] transition-all"
>
<IconTrash size={12} />
</button>
{/if}
</div>
<!-- Message -->
+10 -1
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { fetchEventLog, fetchEventLogStats, clearAllEvents } from '$lib/api';
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
@@ -275,6 +275,15 @@
<EventLogEntryComponent
{entry}
isNew={newEventIds.has(entry.id)}
ondelete={async (id) => {
try {
await deleteEvent(id);
events = events.filter(e => e.id !== id);
stats = { ...stats, total: Math.max(0, stats.total - 1) };
} catch (err) {
toasts.error(err instanceof Error ? err.message : 'Failed to delete event');
}
}}
/>
{/each}
</div>
+24 -2
View File
@@ -40,6 +40,28 @@
let errors = $state<Record<string, string>>({});
// Convert Go duration string (e.g., "5m", "300s", "1h") to seconds string.
function parseDurationToSeconds(dur: string): string {
if (!dur) return '60';
const m = dur.match(/^(\d+)(s|m|h)?$/);
if (!m) return dur;
const n = parseInt(m[1], 10);
switch (m[2]) {
case 'h': return String(n * 3600);
case 'm': return String(n * 60);
default: return String(n);
}
}
// Convert seconds to a Go duration string for the API.
function secondsToDuration(sec: string | number): string {
const n = parseInt(String(sec), 10);
if (isNaN(n) || n <= 0) return '60s';
if (n >= 3600 && n % 3600 === 0) return `${n / 3600}h`;
if (n >= 60 && n % 60 === 0) return `${n / 60}m`;
return `${n}s`;
}
function validateDomain(value: string): string {
if (!value.trim()) return '';
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain');
@@ -87,7 +109,7 @@
serverIp = settings.server_ip ?? '';
network = settings.network ?? '';
subdomainPattern = settings.subdomain_pattern ?? '';
pollingInterval = settings.polling_interval ?? '';
pollingInterval = parseDurationToSeconds(settings.polling_interval ?? '60');
baseVolumePath = settings.base_volume_path ?? '';
notificationUrl = settings.notification_url ?? '';
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
@@ -116,7 +138,7 @@
try {
const payload: Record<string, unknown> = {
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
subdomain_pattern: subdomainPattern.trim(), polling_interval: String(pollingInterval ?? '').trim(),
subdomain_pattern: subdomainPattern.trim(), polling_interval: secondsToDuration(pollingInterval),
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
proxy_provider: proxyProvider,
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),