feat: enable proxy toggle on quick deploy, event log clearing, and UX fixes

- Add enable_proxy toggle to Quick Deploy form (defaults to on)
- Add DELETE /api/events/log/{id} and DELETE /api/events/log endpoints
- Add Clear All button with confirmation on Events page
- Rename "NPM Proxy" to "Enable Proxy" on stage form (provider-agnostic)
- Fix polling interval validation (min 60s) and number input trim errors
- Fix domain field no longer required in settings
This commit is contained in:
2026-04-05 01:50:19 +03:00
parent 61febefca9
commit c26c41e6a1
10 changed files with 134 additions and 13 deletions
+12 -6
View File
@@ -96,12 +96,13 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
type quickDeployRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check
Name string `json:"name"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
}
// quickDeploy handles POST /api/deploy/quick.
@@ -167,12 +168,17 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
}
// Create default stage.
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: project.ID,
Name: "dev",
TagPattern: "*",
AutoDeploy: true,
MaxInstances: 1,
EnableProxy: enableProxy,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
+28
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -46,3 +48,29 @@ func (s *Server) getEventLogStats(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, stats)
}
// deleteEvent handles DELETE /api/events/log/{id}.
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid event ID")
return
}
if err := s.store.DeleteEvent(id); err != nil {
slog.Error("failed to delete event", "id", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// clearEvents handles DELETE /api/events/log.
func (s *Server) clearEvents(w http.ResponseWriter, r *http.Request) {
cleared, err := s.store.ClearAllEvents()
if err != nil {
slog.Error("failed to clear events", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]any{"status": "cleared", "count": cleared})
}
+4
View File
@@ -274,6 +274,10 @@ func (s *Server) Router() chi.Router {
// Config export (reveals project/infra details).
r.Get("/config/export", s.exportConfig)
// Event log management.
r.Delete("/events/log/{id}", s.deleteEvent)
r.Delete("/events/log", s.clearEvents)
// Auth management.
r.Get("/auth/settings", s.getAuthSettings)
r.Put("/auth/settings", s.updateAuthSettings)
+22
View File
@@ -153,6 +153,28 @@ func (s *Store) GetEventStats() (EventLogStats, error) {
}
// PruneEvents deletes event log entries older than the given number of days.
// DeleteEvent removes a single event log entry by ID.
func (s *Store) DeleteEvent(id int64) error {
result, err := s.db.Exec(`DELETE FROM event_log WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete event: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("event %d: %w", id, ErrNotFound)
}
return nil
}
// ClearAllEvents removes all event log entries.
func (s *Store) ClearAllEvents() (int64, error) {
result, err := s.db.Exec(`DELETE FROM event_log`)
if err != nil {
return 0, fmt.Errorf("clear events: %w", err)
}
return result.RowsAffected()
}
func (s *Store) PruneEvents(olderThanDays int) (int64, error) {
if olderThanDays < 1 {
return 0, fmt.Errorf("prune events: olderThanDays must be >= 1, got %d", olderThanDays)
+9
View File
@@ -215,6 +215,7 @@ export function quickDeploy(data: {
registry?: string;
port?: number;
force?: boolean;
enable_proxy?: boolean;
}): Promise<{ project: Project; status: string }> {
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
}
@@ -540,6 +541,14 @@ export function fetchEventLogStats(): Promise<EventLogStats> {
return get<EventLogStats>('/api/events/log/stats');
}
export function deleteEvent(id: number): Promise<{ status: string }> {
return del<{ status: string }>(`/api/events/log/${id}`);
}
export function clearAllEvents(): Promise<{ status: string; count: number }> {
return del<{ status: string; count: number }>('/api/events/log');
}
// ── Stale Containers ────────────────────────────────────────────────
export function fetchStaleContainers(): Promise<StaleContainer[]> {
+6
View File
@@ -102,6 +102,7 @@
"tagPatternHelp": "Glob pattern (e.g., dev-*, v*)",
"maxInstances": "Max Instances",
"autoDeployLabel": "Auto Deploy",
"enableProxy": "Enable Proxy",
"npmProxy": "NPM Proxy",
"creating": "Creating...",
"createStage": "Create Stage",
@@ -658,6 +659,11 @@
"noEventsDesc": "Events will appear here as they occur.",
"loadMore": "Load more",
"newEvents": "new events",
"clearAll": "Clear All",
"clearAllTitle": "Clear Event Log",
"clearAllMessage": "This will permanently delete all event log entries. This cannot be undone.",
"cleared": "Cleared {count} events",
"clearFailed": "Failed to clear events",
"filter": {
"severity": "Severity",
"source": "Source",
+6
View File
@@ -102,6 +102,7 @@
"tagPatternHelp": "Glob-шаблон (напр., dev-*, v*)",
"maxInstances": "Макс. экземпляров",
"autoDeployLabel": "Авто-деплой",
"enableProxy": "Включить прокси",
"npmProxy": "NPM прокси",
"creating": "Создание...",
"createStage": "Создать стадию",
@@ -658,6 +659,11 @@
"noEventsDesc": "События будут отображаться здесь по мере их возникновения.",
"loadMore": "Загрузить ещё",
"newEvents": "новых событий",
"clearAll": "Очистить всё",
"clearAllTitle": "Очистить журнал событий",
"clearAllMessage": "Все записи журнала событий будут удалены безвозвратно.",
"cleared": "Удалено {count} событий",
"clearFailed": "Не удалось очистить события",
"filter": {
"severity": "Уровень",
"source": "Источник",
+8 -1
View File
@@ -2,6 +2,7 @@
import { inspectImage, quickDeploy, listRegistries, listRegistryImages, deployInstance } from '$lib/api';
import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
@@ -20,6 +21,7 @@
let stage = $state('dev');
let subdomain = $state('');
let envVars = $state('');
let enableProxy = $state(true);
let errors = $state<Record<string, string>>({});
@@ -140,7 +142,7 @@
if (!validateAll()) return;
deploying = true;
try {
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force });
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy });
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
// Redirect to the new project page.
if (result.project?.id) {
@@ -288,6 +290,11 @@
<div class="mt-4">
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
</div>
<div class="mt-4 flex items-center gap-3">
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
</div>
</div>
<!-- Step 3 -->
+37 -4
View File
@@ -5,7 +5,9 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { fetchEventLog, fetchEventLogStats } from '$lib/api';
import { fetchEventLog, fetchEventLogStats, clearAllEvents } 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';
import type { EventLogEntry, EventLogStats } from '$lib/types';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
@@ -35,6 +37,7 @@
let sseConnection: SSEConnection | null = null;
let listEl: HTMLDivElement | undefined = $state();
let showClearConfirm = $state(false);
// ── Date range to ISO string ─────────────────────────────────
@@ -212,9 +215,18 @@
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('events.title')}</h1>
{#if stats.total > 0}
<span class="text-xs text-[var(--text-tertiary)] tabular-nums">{stats.total} total</span>
{/if}
<div class="flex items-center gap-3">
{#if stats.total > 0}
<span class="text-xs text-[var(--text-tertiary)] tabular-nums">{stats.total} total</span>
<button
type="button"
onclick={() => { showClearConfirm = true; }}
class="rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--color-danger)] transition-colors"
>
{$t('events.clearAll')}
</button>
{/if}
</div>
</div>
<!-- Filter bar (includes severity stats as pill counts) -->
@@ -285,3 +297,24 @@
{/if}
{/if}
</div>
<ConfirmDialog
open={showClearConfirm}
title={$t('events.clearAllTitle')}
message={$t('events.clearAllMessage')}
confirmLabel={$t('events.clearAll')}
confirmVariant="danger"
onconfirm={async () => {
showClearConfirm = false;
try {
const result = await clearAllEvents();
toasts.success($t('events.cleared', { count: String(result.count) }));
events = [];
stats = { info: 0, warn: 0, error: 0, total: 0 };
offset = 0;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('events.clearFailed'));
}
}}
oncancel={() => { showClearConfirm = false; }}
/>
+2 -2
View File
@@ -349,9 +349,9 @@
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.npmProxy')}</label>
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.enableProxy')}</label>
<div class="flex items-center h-[38px]">
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.npmProxy')} />
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
</div>
</div>
</div>