fix(observability): router conflict, logout button, missing i18n

- Fix chi duplicate Route() panic by consolidating read/write routes
  into single Route blocks with nested admin Group
- Add logout button to sidebar with token cleanup
- Add missing settingsAuth.password i18n key
This commit is contained in:
2026-03-30 12:26:22 +03:00
parent e0a648fb0c
commit 71aeb615b3
6 changed files with 102 additions and 61 deletions
+63 -55
View File
@@ -138,55 +138,10 @@ func (s *Server) Router() chi.Router {
r.Get("/stages/{stage}/instances", s.listInstances) r.Get("/stages/{stage}/instances", s.listInstances)
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
r.Get("/volumes", s.listVolumes) r.Get("/volumes", s.listVolumes)
})
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
r.Get("/events", s.streamEvents)
r.Get("/events/log", s.listEventLog)
r.Get("/events/log/stats", s.getEventLogStats)
r.Get("/registries", s.listRegistries)
r.Route("/registries/{id}", func(r chi.Router) {
r.Get("/tags/*", s.listRegistryTags)
r.Get("/images", s.listRegistryImages)
})
r.Get("/settings", s.getSettings)
r.Get("/settings/npm-certificates", s.listNpmCertificates)
// Stale container endpoints. // Admin-only project mutations.
r.Get("/containers/stale", s.listStaleContainers) r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Proxy endpoints (read-only for any authenticated user).
r.Get("/proxies", s.listProxies)
r.Get("/proxies/all", s.listAllProxies)
r.Route("/proxies/{id}", func(r chi.Router) {
r.Get("/", s.getProxy)
})
// Admin-only routes: require admin role.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Proxy mutation endpoints.
r.Post("/proxies/validate", s.validateProxy)
r.Post("/proxies", s.createProxy)
r.Route("/proxies/{id}", func(r chi.Router) {
r.Put("/", s.updateProxy)
r.Delete("/", s.deleteProxy)
})
// Config export (reveals project/infra details).
r.Get("/config/export", s.exportConfig)
// Auth management.
r.Get("/auth/settings", s.getAuthSettings)
r.Put("/auth/settings", s.updateAuthSettings)
r.Get("/auth/users", s.listUsers)
r.Post("/auth/users", s.createUser)
r.Delete("/auth/users/{uid}", s.deleteUser)
// Project mutation endpoints.
r.Post("/projects", s.createProject)
r.Route("/projects/{id}", func(r chi.Router) {
r.Put("/", s.updateProject) r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject) r.Delete("/", s.deleteProject)
@@ -214,20 +169,73 @@ func (s *Server) Router() chi.Router {
r.Put("/volumes/{volId}", s.updateVolume) r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume) r.Delete("/volumes/{volId}", s.deleteVolume)
}) })
})
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
r.Get("/events", s.streamEvents)
r.Get("/events/log", s.listEventLog)
r.Get("/events/log/stats", s.getEventLogStats)
r.Get("/registries", s.listRegistries)
r.Route("/registries/{id}", func(r chi.Router) {
r.Get("/tags/*", s.listRegistryTags)
r.Get("/images", s.listRegistryImages)
// Admin-only registry mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateRegistry)
r.Delete("/", s.deleteRegistry)
r.Post("/test", s.testRegistry)
})
})
r.Get("/settings", s.getSettings)
r.Get("/settings/npm-certificates", s.listNpmCertificates)
// Stale container endpoints (read).
r.Get("/containers/stale", s.listStaleContainers)
// Proxy endpoints (read-only for any authenticated user).
r.Get("/proxies", s.listProxies)
r.Get("/proxies/all", s.listAllProxies)
r.Route("/proxies/{id}", func(r chi.Router) {
r.Get("/", s.getProxy)
// Admin-only proxy mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateProxy)
r.Delete("/", s.deleteProxy)
})
})
// Admin-only routes: require admin role.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Config export (reveals project/infra details).
r.Get("/config/export", s.exportConfig)
// Auth management.
r.Get("/auth/settings", s.getAuthSettings)
r.Put("/auth/settings", s.updateAuthSettings)
r.Get("/auth/users", s.listUsers)
r.Post("/auth/users", s.createUser)
r.Delete("/auth/users/{uid}", s.deleteUser)
// Project creation.
r.Post("/projects", s.createProject)
// Quick deploy endpoints. // Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy) r.Post("/deploy/quick", s.quickDeploy)
// Registry mutation endpoints. // Registry creation.
r.Post("/registries", s.createRegistry) r.Post("/registries", s.createRegistry)
r.Route("/registries/{id}", func(r chi.Router) {
r.Put("/", s.updateRegistry)
r.Delete("/", s.deleteRegistry)
r.Post("/test", s.testRegistry)
})
// Stale container cleanup endpoints (admin-only). // Proxy mutation endpoints.
r.Post("/proxies/validate", s.validateProxy)
r.Post("/proxies", s.createProxy)
// Stale container cleanup endpoints.
// Bulk route must be registered before parameterized route. // Bulk route must be registered before parameterized route.
r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers) r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers)
r.Post("/containers/stale/{id}/cleanup", s.cleanupStaleContainer) r.Post("/containers/stale/{id}/cleanup", s.cleanupStaleContainer)
@@ -0,0 +1,9 @@
<script lang="ts">
interface Props { size?: number; class?: string; }
const { size = 24, class: className = '' }: Props = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
+1
View File
@@ -47,3 +47,4 @@ export { default as IconWifi } from './IconWifi.svelte';
export { default as IconRefresh } from './IconRefresh.svelte'; export { default as IconRefresh } from './IconRefresh.svelte';
export { default as IconProxies } from './IconProxies.svelte'; export { default as IconProxies } from './IconProxies.svelte';
export { default as IconEvents } from './IconEvents.svelte'; export { default as IconEvents } from './IconEvents.svelte';
export { default as IconLogout } from './IconLogout.svelte';
+4 -2
View File
@@ -9,7 +9,8 @@
"deploy": "Deploy", "deploy": "Deploy",
"proxies": "Proxies", "proxies": "Proxies",
"events": "Events", "events": "Events",
"settings": "Settings" "settings": "Settings",
"logout": "Log out"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -311,7 +312,8 @@
"createFailed": "Failed to create user", "createFailed": "Failed to create user",
"deleteFailed": "Failed to delete user", "deleteFailed": "Failed to delete user",
"deleteConfirm": "Are you sure you want to delete this user?", "deleteConfirm": "Are you sure you want to delete this user?",
"usernameRequired": "Username and password are required" "usernameRequired": "Username and password are required",
"password": "Password"
}, },
"login": { "login": {
"title": "Docker Watcher", "title": "Docker Watcher",
+4 -2
View File
@@ -9,7 +9,8 @@
"deploy": "Деплой", "deploy": "Деплой",
"proxies": "Прокси", "proxies": "Прокси",
"events": "События", "events": "События",
"settings": "Настройки" "settings": "Настройки",
"logout": "Выйти"
}, },
"dashboard": { "dashboard": {
"title": "Панель управления", "title": "Панель управления",
@@ -311,7 +312,8 @@
"createFailed": "Не удалось создать пользователя", "createFailed": "Не удалось создать пользователя",
"deleteFailed": "Не удалось удалить пользователя", "deleteFailed": "Не удалось удалить пользователя",
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?", "deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
"usernameRequired": "Имя пользователя и пароль обязательны" "usernameRequired": "Имя пользователя и пароль обязательны",
"password": "Пароль"
}, },
"login": { "login": {
"title": "Docker Watcher", "title": "Docker Watcher",
+21 -2
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX } from '$lib/components/icons'; import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status'; import { instanceStatusStore } from '$lib/stores/instance-status';
import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { resolvedTheme, applyTheme } from '$lib/stores/theme';
@@ -58,6 +58,15 @@
sidebarOpen = false; sidebarOpen = false;
}); });
function logout() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('auth_token');
}
sseConnection?.close();
sseConnection = null;
window.location.href = '/login';
}
onMount(() => { onMount(() => {
sseConnection = connectGlobalEvents({ sseConnection = connectGlobalEvents({
onInstanceStatus(payload) { onInstanceStatus(payload) {
@@ -151,7 +160,17 @@
<ThemeToggle /> <ThemeToggle />
<LocaleSwitcher /> <LocaleSwitcher />
</div> </div>
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p> <div class="flex items-center justify-between">
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
<button
onclick={logout}
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--color-danger)]"
title={$t('nav.logout')}
>
<IconLogout size={14} />
{$t('nav.logout')}
</button>
</div>
</div> </div>
</aside> </aside>