From b21c402dc7a1f17febd2f373eafef51274c59e23 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 14:56:11 +0100 Subject: [PATCH 01/17] userSettings simplifications --- backend/app/domain/user/settings_models.py | 2 +- backend/app/schemas_pydantic/user_settings.py | 8 +- docs/reference/openapi.json | 27 +- .../src/lib/__tests__/settings-cache.test.ts | 206 ---------------- .../src/lib/__tests__/user-settings.test.ts | 230 +++++++----------- frontend/src/lib/api/types.gen.ts | 20 -- frontend/src/lib/auth-init.ts | 2 + frontend/src/lib/settings-cache.ts | 83 ------- frontend/src/lib/user-settings.ts | 66 ++--- frontend/src/routes/Editor.svelte | 65 +---- frontend/src/routes/Settings.svelte | 25 +- frontend/src/stores/__tests__/theme.test.ts | 2 +- frontend/src/stores/theme.ts | 8 +- frontend/src/stores/userSettings.ts | 30 +++ 14 files changed, 159 insertions(+), 615 deletions(-) delete mode 100644 frontend/src/lib/__tests__/settings-cache.test.ts delete mode 100644 frontend/src/lib/settings-cache.ts create mode 100644 frontend/src/stores/userSettings.ts diff --git a/backend/app/domain/user/settings_models.py b/backend/app/domain/user/settings_models.py index 66f2f71..382551d 100644 --- a/backend/app/domain/user/settings_models.py +++ b/backend/app/domain/user/settings_models.py @@ -20,7 +20,7 @@ class DomainNotificationSettings: @dataclass class DomainEditorSettings: - theme: str = "one-dark" + theme: str = "auto" font_size: int = 14 tab_size: int = 4 use_tabs: bool = False diff --git a/backend/app/schemas_pydantic/user_settings.py b/backend/app/schemas_pydantic/user_settings.py index 2066ca4..258de96 100644 --- a/backend/app/schemas_pydantic/user_settings.py +++ b/backend/app/schemas_pydantic/user_settings.py @@ -21,18 +21,12 @@ class NotificationSettings(BaseModel): class EditorSettings(BaseModel): """Code editor preferences""" - theme: str = "one-dark" + theme: str = "auto" font_size: int = 14 tab_size: int = 4 use_tabs: bool = False word_wrap: bool = True show_line_numbers: bool = True - # These are always on in the editor, not user-configurable - font_family: str = "Monaco, Consolas, 'Courier New', monospace" - auto_complete: bool = True - bracket_matching: bool = True - highlight_active_line: bool = True - default_language: str = "python" @field_validator("font_size") @classmethod diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json index 2e24358..c142293 100644 --- a/docs/reference/openapi.json +++ b/docs/reference/openapi.json @@ -4991,7 +4991,7 @@ "theme": { "type": "string", "title": "Theme", - "default": "one-dark" + "default": "auto" }, "font_size": { "type": "integer", @@ -5017,31 +5017,6 @@ "type": "boolean", "title": "Show Line Numbers", "default": true - }, - "font_family": { - "type": "string", - "title": "Font Family", - "default": "Monaco, Consolas, 'Courier New', monospace" - }, - "auto_complete": { - "type": "boolean", - "title": "Auto Complete", - "default": true - }, - "bracket_matching": { - "type": "boolean", - "title": "Bracket Matching", - "default": true - }, - "highlight_active_line": { - "type": "boolean", - "title": "Highlight Active Line", - "default": true - }, - "default_language": { - "type": "string", - "title": "Default Language", - "default": "python" } }, "type": "object", diff --git a/frontend/src/lib/__tests__/settings-cache.test.ts b/frontend/src/lib/__tests__/settings-cache.test.ts deleted file mode 100644 index 1bec5ec..0000000 --- a/frontend/src/lib/__tests__/settings-cache.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { get } from 'svelte/store'; - -const CACHE_KEY = 'integr8scode-user-settings'; -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -describe('settings-cache', () => { - let localStorageData: Record = {}; - - beforeEach(async () => { - // Reset localStorage mock - localStorageData = {}; - vi.mocked(localStorage.getItem).mockImplementation((key: string) => localStorageData[key] ?? null); - vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => { - localStorageData[key] = value; - }); - vi.mocked(localStorage.removeItem).mockImplementation((key: string) => { - delete localStorageData[key]; - }); - - // Reset modules to get fresh store state - vi.resetModules(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('getCachedSettings', () => { - it('returns null when no cache exists', async () => { - const { getCachedSettings } = await import('../settings-cache'); - expect(getCachedSettings()).toBe(null); - }); - - it('returns cached settings when valid', async () => { - const settings = { theme: 'dark', editor: { fontSize: 14 } }; - localStorageData[CACHE_KEY] = JSON.stringify({ - data: settings, - timestamp: Date.now() - }); - - const { getCachedSettings } = await import('../settings-cache'); - expect(getCachedSettings()).toEqual(settings); - }); - - it('returns null and clears cache when expired', async () => { - const settings = { theme: 'light' }; - localStorageData[CACHE_KEY] = JSON.stringify({ - data: settings, - timestamp: Date.now() - CACHE_TTL - 1000 // expired - }); - - const { getCachedSettings } = await import('../settings-cache'); - expect(getCachedSettings()).toBe(null); - expect(localStorage.removeItem).toHaveBeenCalledWith(CACHE_KEY); - }); - - it('returns null and clears cache on parse error', async () => { - localStorageData[CACHE_KEY] = 'invalid json{'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - - const { getCachedSettings } = await import('../settings-cache'); - expect(getCachedSettings()).toBe(null); - expect(localStorage.removeItem).toHaveBeenCalledWith(CACHE_KEY); - }); - }); - - describe('setCachedSettings', () => { - it('saves settings to localStorage', async () => { - const { setCachedSettings } = await import('../settings-cache'); - const settings = { theme: 'dark', editor: { fontSize: 16 } }; - - setCachedSettings(settings as any); - - expect(localStorage.setItem).toHaveBeenCalledWith( - CACHE_KEY, - expect.stringContaining('"theme":"dark"') - ); - }); - - it('updates the settingsCache store', async () => { - const { setCachedSettings, settingsCache } = await import('../settings-cache'); - const settings = { theme: 'system', editor: { tabSize: 2 } }; - - setCachedSettings(settings as any); - - expect(get(settingsCache)).toEqual(settings); - }); - - it('includes timestamp in cached data', async () => { - const before = Date.now(); - const { setCachedSettings } = await import('../settings-cache'); - - setCachedSettings({ theme: 'light' } as any); - - const saved = JSON.parse(localStorageData[CACHE_KEY]); - expect(saved.timestamp).toBeGreaterThanOrEqual(before); - expect(saved.timestamp).toBeLessThanOrEqual(Date.now()); - }); - - it('handles localStorage errors gracefully', async () => { - vi.mocked(localStorage.setItem).mockImplementation(() => { - throw new Error('QuotaExceededError'); - }); - vi.spyOn(console, 'error').mockImplementation(() => {}); - - const { setCachedSettings } = await import('../settings-cache'); - - // Should not throw - expect(() => setCachedSettings({ theme: 'dark' } as any)).not.toThrow(); - }); - }); - - describe('clearCache', () => { - it('removes settings from localStorage', async () => { - localStorageData[CACHE_KEY] = JSON.stringify({ data: {}, timestamp: Date.now() }); - - const { clearCache } = await import('../settings-cache'); - clearCache(); - - expect(localStorage.removeItem).toHaveBeenCalledWith(CACHE_KEY); - }); - - it('sets settingsCache store to null', async () => { - const { setCachedSettings, clearCache, settingsCache } = await import('../settings-cache'); - - setCachedSettings({ theme: 'dark' } as any); - expect(get(settingsCache)).not.toBe(null); - - clearCache(); - expect(get(settingsCache)).toBe(null); - }); - }); - - describe('updateCachedSetting', () => { - it('does nothing when cache is empty', async () => { - const { updateCachedSetting, settingsCache } = await import('../settings-cache'); - - updateCachedSetting('theme', 'dark'); - - expect(get(settingsCache)).toBe(null); - }); - - it('updates a top-level setting', async () => { - const { setCachedSettings, updateCachedSetting, settingsCache } = await import('../settings-cache'); - - setCachedSettings({ theme: 'light', editor: {} } as any); - updateCachedSetting('theme', 'dark'); - - expect(get(settingsCache)?.theme).toBe('dark'); - }); - - it('updates a nested setting', async () => { - const { setCachedSettings, updateCachedSetting, settingsCache } = await import('../settings-cache'); - - setCachedSettings({ theme: 'light', editor: { fontSize: 12 } } as any); - updateCachedSetting('editor.fontSize', 16); - - const current = get(settingsCache); - expect((current as any)?.editor?.fontSize).toBe(16); - }); - - it('creates intermediate objects for deep paths', async () => { - const { setCachedSettings, updateCachedSetting, settingsCache } = await import('../settings-cache'); - - setCachedSettings({ theme: 'light' } as any); - updateCachedSetting('editor.preferences.autoSave', true); - - const current = get(settingsCache) as any; - expect(current?.editor?.preferences?.autoSave).toBe(true); - }); - - it('persists updated setting to localStorage', async () => { - const { setCachedSettings, updateCachedSetting } = await import('../settings-cache'); - - setCachedSettings({ theme: 'light' } as any); - vi.mocked(localStorage.setItem).mockClear(); - - updateCachedSetting('theme', 'dark'); - - expect(localStorage.setItem).toHaveBeenCalledWith( - CACHE_KEY, - expect.stringContaining('"theme":"dark"') - ); - }); - }); - - describe('settingsCache store', () => { - it('initializes from localStorage on module load', async () => { - const settings = { theme: 'dark' }; - localStorageData[CACHE_KEY] = JSON.stringify({ - data: settings, - timestamp: Date.now() - }); - - const { settingsCache } = await import('../settings-cache'); - - expect(get(settingsCache)).toEqual(settings); - }); - - it('initializes to null when no cached settings', async () => { - const { settingsCache } = await import('../settings-cache'); - expect(get(settingsCache)).toBe(null); - }); - }); -}); diff --git a/frontend/src/lib/__tests__/user-settings.test.ts b/frontend/src/lib/__tests__/user-settings.test.ts index 2159ad6..1745286 100644 --- a/frontend/src/lib/__tests__/user-settings.test.ts +++ b/frontend/src/lib/__tests__/user-settings.test.ts @@ -1,35 +1,27 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -// Mock the API functions const mockGetUserSettings = vi.fn(); -const mockUpdateTheme = vi.fn(); -const mockUpdateEditorSettings = vi.fn(); +const mockUpdateUserSettings = vi.fn(); vi.mock('../api', () => ({ getUserSettingsApiV1UserSettingsGet: (...args: unknown[]) => mockGetUserSettings(...args), - updateThemeApiV1UserSettingsThemePut: (...args: unknown[]) => mockUpdateTheme(...args), - updateEditorSettingsApiV1UserSettingsEditorPut: (...args: unknown[]) => mockUpdateEditorSettings(...args), + updateUserSettingsApiV1UserSettingsPut: (...args: unknown[]) => mockUpdateUserSettings(...args), })); -// Mock the settings-cache module -const mockGetCachedSettings = vi.fn(); -const mockSetCachedSettings = vi.fn(); -const mockUpdateCachedSetting = vi.fn(); +const mockSetUserSettings = vi.fn(); +const mockUpdateSettings = vi.fn(); -vi.mock('../settings-cache', () => ({ - getCachedSettings: () => mockGetCachedSettings(), - setCachedSettings: (settings: unknown) => mockSetCachedSettings(settings), - updateCachedSetting: (path: string, value: unknown) => mockUpdateCachedSetting(path, value), +vi.mock('../../stores/userSettings', () => ({ + setUserSettings: (settings: unknown) => mockSetUserSettings(settings), + updateSettings: (partial: unknown) => mockUpdateSettings(partial), })); -// Mock the theme store const mockSetThemeLocal = vi.fn(); vi.mock('../../stores/theme', () => ({ setThemeLocal: (theme: string) => mockSetThemeLocal(theme), })); -// Mock the auth store let mockIsAuthenticated = true; vi.mock('../../stores/auth', () => ({ @@ -43,24 +35,18 @@ vi.mock('../../stores/auth', () => ({ describe('user-settings', () => { beforeEach(async () => { - // Reset all mocks mockGetUserSettings.mockReset(); - mockUpdateTheme.mockReset(); - mockUpdateEditorSettings.mockReset(); - mockGetCachedSettings.mockReset(); - mockSetCachedSettings.mockReset(); - mockUpdateCachedSetting.mockReset(); + mockUpdateUserSettings.mockReset(); + mockSetUserSettings.mockReset(); + mockUpdateSettings.mockReset(); mockSetThemeLocal.mockReset(); - // Default to authenticated mockIsAuthenticated = true; - // Suppress console output vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); - // Clear module cache vi.resetModules(); }); @@ -68,90 +54,8 @@ describe('user-settings', () => { vi.restoreAllMocks(); }); - describe('saveThemeSetting', () => { - it('returns undefined when not authenticated', async () => { - mockIsAuthenticated = false; - vi.resetModules(); - - const { saveThemeSetting } = await import('../user-settings'); - const result = await saveThemeSetting('dark'); - - expect(result).toBeUndefined(); - expect(mockUpdateTheme).not.toHaveBeenCalled(); - }); - - it('calls API with correct theme', async () => { - mockUpdateTheme.mockResolvedValue({ data: {}, error: null }); - - const { saveThemeSetting } = await import('../user-settings'); - await saveThemeSetting('dark'); - - expect(mockUpdateTheme).toHaveBeenCalledWith({ - body: { theme: 'dark' } - }); - }); - - it('updates cache on success', async () => { - mockUpdateTheme.mockResolvedValue({ data: {}, error: null }); - - const { saveThemeSetting } = await import('../user-settings'); - await saveThemeSetting('system'); - - expect(mockUpdateCachedSetting).toHaveBeenCalledWith('theme', 'system'); - }); - - it('returns true on success', async () => { - mockUpdateTheme.mockResolvedValue({ data: {}, error: null }); - - const { saveThemeSetting } = await import('../user-settings'); - const result = await saveThemeSetting('light'); - - expect(result).toBe(true); - }); - - it('returns false on API error', async () => { - mockUpdateTheme.mockResolvedValue({ data: null, error: { detail: 'Server error' } }); - - const { saveThemeSetting } = await import('../user-settings'); - const result = await saveThemeSetting('dark'); - - expect(result).toBe(false); - }); - - it('returns false on network error', async () => { - mockUpdateTheme.mockRejectedValue(new Error('Network error')); - - const { saveThemeSetting } = await import('../user-settings'); - const result = await saveThemeSetting('dark'); - - expect(result).toBe(false); - }); - }); - describe('loadUserSettings', () => { - it('returns cached settings if available', async () => { - const cachedSettings = { theme: 'dark', editor: { fontSize: 14 } }; - mockGetCachedSettings.mockReturnValue(cachedSettings); - - const { loadUserSettings } = await import('../user-settings'); - const result = await loadUserSettings(); - - expect(result).toEqual(cachedSettings); - expect(mockGetUserSettings).not.toHaveBeenCalled(); - }); - - it('applies cached theme', async () => { - const cachedSettings = { theme: 'dark' }; - mockGetCachedSettings.mockReturnValue(cachedSettings); - - const { loadUserSettings } = await import('../user-settings'); - await loadUserSettings(); - - expect(mockSetThemeLocal).toHaveBeenCalledWith('dark'); - }); - - it('fetches from API when no cache', async () => { - mockGetCachedSettings.mockReturnValue(null); + it('fetches from API', async () => { mockGetUserSettings.mockResolvedValue({ data: { theme: 'light', editor: {} }, error: null @@ -163,19 +67,17 @@ describe('user-settings', () => { expect(mockGetUserSettings).toHaveBeenCalledWith({}); }); - it('caches API response', async () => { - const apiSettings = { theme: 'system', editor: { tabSize: 4 } }; - mockGetCachedSettings.mockReturnValue(null); + it('updates store with API response', async () => { + const apiSettings = { theme: 'system', editor: { tab_size: 4 } }; mockGetUserSettings.mockResolvedValue({ data: apiSettings, error: null }); const { loadUserSettings } = await import('../user-settings'); await loadUserSettings(); - expect(mockSetCachedSettings).toHaveBeenCalledWith(apiSettings); + expect(mockSetUserSettings).toHaveBeenCalledWith(apiSettings); }); it('applies theme from API response', async () => { - mockGetCachedSettings.mockReturnValue(null); mockGetUserSettings.mockResolvedValue({ data: { theme: 'dark' }, error: null @@ -188,7 +90,6 @@ describe('user-settings', () => { }); it('returns undefined on API error', async () => { - mockGetCachedSettings.mockReturnValue(null); mockGetUserSettings.mockResolvedValue({ data: null, error: { detail: 'Not found' } @@ -201,7 +102,6 @@ describe('user-settings', () => { }); it('returns undefined on network error', async () => { - mockGetCachedSettings.mockReturnValue(null); mockGetUserSettings.mockRejectedValue(new Error('Network error')); const { loadUserSettings } = await import('../user-settings'); @@ -211,7 +111,10 @@ describe('user-settings', () => { }); it('does not apply theme when not in settings', async () => { - mockGetCachedSettings.mockReturnValue({ editor: {} }); + mockGetUserSettings.mockResolvedValue({ + data: { editor: {} }, + error: null + }); const { loadUserSettings } = await import('../user-settings'); await loadUserSettings(); @@ -220,66 +123,105 @@ describe('user-settings', () => { }); }); - describe('saveEditorSettings', () => { - it('returns undefined when not authenticated', async () => { + describe('saveUserSettings', () => { + it('returns false when not authenticated', async () => { mockIsAuthenticated = false; vi.resetModules(); - const { saveEditorSettings } = await import('../user-settings'); - const result = await saveEditorSettings({ fontSize: 14 } as any); + const { saveUserSettings } = await import('../user-settings'); + const result = await saveUserSettings({ theme: 'dark' }); - expect(result).toBeUndefined(); - expect(mockUpdateEditorSettings).not.toHaveBeenCalled(); + expect(result).toBe(false); + expect(mockUpdateUserSettings).not.toHaveBeenCalled(); }); - it('calls API with editor settings', async () => { - mockUpdateEditorSettings.mockResolvedValue({ data: {}, error: null }); - const editorSettings = { fontSize: 16, tabSize: 2, theme: 'monokai' }; + it('calls API with partial settings', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); - const { saveEditorSettings } = await import('../user-settings'); - await saveEditorSettings(editorSettings as any); + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ theme: 'dark' }); - expect(mockUpdateEditorSettings).toHaveBeenCalledWith({ - body: editorSettings + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + body: { theme: 'dark' } }); }); - it('updates cache on success', async () => { - mockUpdateEditorSettings.mockResolvedValue({ data: {}, error: null }); - const editorSettings = { fontSize: 18 }; + it('can save editor settings', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + const editorSettings = { font_size: 16, tab_size: 2 }; - const { saveEditorSettings } = await import('../user-settings'); - await saveEditorSettings(editorSettings as any); + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ editor: editorSettings }); - expect(mockUpdateCachedSetting).toHaveBeenCalledWith('editor', editorSettings); + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + body: { editor: editorSettings } + }); + }); + + it('can save multiple settings at once', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ theme: 'dark', editor: { font_size: 18 } }); + + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + body: { theme: 'dark', editor: { font_size: 18 } } + }); + }); + + it('updates store on success', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ theme: 'system' }); + + expect(mockUpdateSettings).toHaveBeenCalledWith({ theme: 'system' }); + }); + + it('applies theme locally when theme is saved', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ theme: 'dark' }); + + expect(mockSetThemeLocal).toHaveBeenCalledWith('dark'); + }); + + it('does not apply theme when only editor settings saved', async () => { + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + + const { saveUserSettings } = await import('../user-settings'); + await saveUserSettings({ editor: { font_size: 16 } }); + + expect(mockSetThemeLocal).not.toHaveBeenCalled(); }); it('returns true on success', async () => { - mockUpdateEditorSettings.mockResolvedValue({ data: {}, error: null }); + mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); - const { saveEditorSettings } = await import('../user-settings'); - const result = await saveEditorSettings({ fontSize: 14 } as any); + const { saveUserSettings } = await import('../user-settings'); + const result = await saveUserSettings({ theme: 'light' }); expect(result).toBe(true); }); it('returns false on API error', async () => { - mockUpdateEditorSettings.mockResolvedValue({ + mockUpdateUserSettings.mockResolvedValue({ data: null, - error: { detail: 'Invalid settings' } + error: { detail: 'Server error' } }); - const { saveEditorSettings } = await import('../user-settings'); - const result = await saveEditorSettings({ fontSize: 14 } as any); + const { saveUserSettings } = await import('../user-settings'); + const result = await saveUserSettings({ theme: 'dark' }); expect(result).toBe(false); }); it('returns false on network error', async () => { - mockUpdateEditorSettings.mockRejectedValue(new Error('Network error')); + mockUpdateUserSettings.mockRejectedValue(new Error('Network error')); - const { saveEditorSettings } = await import('../user-settings'); - const result = await saveEditorSettings({ fontSize: 14 } as any); + const { saveUserSettings } = await import('../user-settings'); + const result = await saveUserSettings({ theme: 'dark' }); expect(result).toBe(false); }); diff --git a/frontend/src/lib/api/types.gen.ts b/frontend/src/lib/api/types.gen.ts index 9ad394b..849b404 100644 --- a/frontend/src/lib/api/types.gen.ts +++ b/frontend/src/lib/api/types.gen.ts @@ -485,26 +485,6 @@ export type EditorSettings = { * Show Line Numbers */ show_line_numbers?: boolean; - /** - * Font Family - */ - font_family?: string; - /** - * Auto Complete - */ - auto_complete?: boolean; - /** - * Bracket Matching - */ - bracket_matching?: boolean; - /** - * Highlight Active Line - */ - highlight_active_line?: boolean; - /** - * Default Language - */ - default_language?: string; }; /** diff --git a/frontend/src/lib/auth-init.ts b/frontend/src/lib/auth-init.ts index cd1592d..31433f8 100644 --- a/frontend/src/lib/auth-init.ts +++ b/frontend/src/lib/auth-init.ts @@ -1,5 +1,6 @@ import { get } from 'svelte/store'; import { isAuthenticated, username, userId, userRole, userEmail, csrfToken, verifyAuth } from '../stores/auth'; +import { clearUserSettings } from '../stores/userSettings'; import { loadUserSettings } from './user-settings'; interface PersistedAuth { @@ -154,6 +155,7 @@ export class AuthInitializer { userRole.set(null); userEmail.set(null); csrfToken.set(null); + clearUserSettings(); localStorage.removeItem('authState'); } diff --git a/frontend/src/lib/settings-cache.ts b/frontend/src/lib/settings-cache.ts deleted file mode 100644 index d5bc544..0000000 --- a/frontend/src/lib/settings-cache.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { writable, get } from 'svelte/store'; -import type { UserSettings } from './api'; - -const browser = typeof window !== 'undefined' && typeof document !== 'undefined'; -const CACHE_KEY = 'integr8scode-user-settings'; -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -interface CacheData { - data: UserSettings; - timestamp: number; -} - -export const settingsCache = writable(null); - -export function getCachedSettings(): UserSettings | null { - if (!browser) return null; - - try { - const cached = localStorage.getItem(CACHE_KEY); - if (!cached) return null; - - const { data, timestamp }: CacheData = JSON.parse(cached); - - if (Date.now() - timestamp > CACHE_TTL) { - localStorage.removeItem(CACHE_KEY); - return null; - } - - return data; - } catch (err) { - console.error('Error reading settings cache:', err); - localStorage.removeItem(CACHE_KEY); - return null; - } -} - -export function setCachedSettings(settings: UserSettings): void { - if (!browser) return; - - try { - const cacheData: CacheData = { - data: settings, - timestamp: Date.now() - }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); - settingsCache.set(settings); - } catch (err) { - console.error('Error saving settings cache:', err); - } -} - -export function clearCache(): void { - if (!browser) return; - localStorage.removeItem(CACHE_KEY); - settingsCache.set(null); -} - -export function updateCachedSetting(path: string, value: unknown): void { - const current = get(settingsCache); - if (!current) return; - - const updated = { ...current } as Record; - const pathParts = path.split('.'); - let target = updated; - - for (let i = 0; i < pathParts.length - 1; i++) { - const part = pathParts[i]; - if (!target[part] || typeof target[part] !== 'object') { - target[part] = {}; - } - target = target[part] as Record; - } - - target[pathParts[pathParts.length - 1]] = value; - setCachedSettings(updated as UserSettings); -} - -if (browser) { - const cached = getCachedSettings(); - if (cached) { - settingsCache.set(cached); - } -} diff --git a/frontend/src/lib/user-settings.ts b/frontend/src/lib/user-settings.ts index a548c3b..a7756ce 100644 --- a/frontend/src/lib/user-settings.ts +++ b/frontend/src/lib/user-settings.ts @@ -1,49 +1,15 @@ import { get } from 'svelte/store'; import { isAuthenticated } from '../stores/auth'; import { setThemeLocal } from '../stores/theme'; -import { getCachedSettings, setCachedSettings, updateCachedSetting } from './settings-cache'; +import { setUserSettings, updateSettings } from '../stores/userSettings'; import { getUserSettingsApiV1UserSettingsGet, - updateThemeApiV1UserSettingsThemePut, - updateEditorSettingsApiV1UserSettingsEditorPut, - type Theme, - type EditorSettings, + updateUserSettingsApiV1UserSettingsPut, type UserSettings, + type UserSettingsUpdate, } from './api'; -export async function saveThemeSetting(theme: string): Promise { - if (!get(isAuthenticated)) { - return; - } - - try { - const { error } = await updateThemeApiV1UserSettingsThemePut({ - body: { theme: theme as Theme } - }); - - if (error) { - console.error('Failed to save theme setting'); - throw error; - } - - updateCachedSetting('theme', theme); - console.log('Theme setting saved:', theme); - return true; - } catch (err) { - console.error('Error saving theme setting:', err); - return false; - } -} - export async function loadUserSettings(): Promise { - const cached = getCachedSettings(); - if (cached) { - if (cached.theme) { - setThemeLocal(cached.theme); - } - return cached; - } - try { const { data, error } = await getUserSettingsApiV1UserSettingsGet({}); @@ -52,7 +18,7 @@ export async function loadUserSettings(): Promise { return; } - setCachedSettings(data); + setUserSettings(data); if (data.theme) { setThemeLocal(data.theme); @@ -64,26 +30,26 @@ export async function loadUserSettings(): Promise { } } -export async function saveEditorSettings(editorSettings: EditorSettings): Promise { - if (!get(isAuthenticated)) { - return; - } +export async function saveUserSettings(partial: UserSettingsUpdate): Promise { + if (!get(isAuthenticated)) return false; try { - const { error } = await updateEditorSettingsApiV1UserSettingsEditorPut({ - body: editorSettings - }); + const { error } = await updateUserSettingsApiV1UserSettingsPut({ body: partial }); if (error) { - console.error('Failed to save editor settings'); - throw error; + console.error('Failed to save user settings:', error); + return false; + } + + updateSettings(partial as Partial); + + if (partial.theme) { + setThemeLocal(partial.theme); } - updateCachedSetting('editor', editorSettings); - console.log('Editor settings saved'); return true; } catch (err) { - console.error('Error saving editor settings:', err); + console.error('Error saving user settings:', err); return false; } } diff --git a/frontend/src/routes/Editor.svelte b/frontend/src/routes/Editor.svelte index 60254f2..c654df9 100644 --- a/frontend/src/routes/Editor.svelte +++ b/frontend/src/routes/Editor.svelte @@ -41,8 +41,8 @@ import AnsiToHtml from 'ansi-to-html'; import DOMPurify from 'dompurify'; import { updateMetaTags, pageMeta } from '../utils/meta'; - import { getCachedSettings, settingsCache } from '../lib/settings-cache'; - import { loadUserSettings, saveEditorSettings } from '../lib/user-settings'; + import { editorSettings as editorSettingsStore } from '../stores/userSettings'; + import { saveUserSettings } from '../lib/user-settings'; let themeCompartment = new Compartment(); let fontSizeCompartment = new Compartment(); @@ -213,67 +213,20 @@ // Verify authentication status on startup await verifyAuth(); - - // Load user settings if authenticated - if (authenticated) { - const userSettings = await loadUserSettings(); - if (userSettings && userSettings.editor) { - editorSettings = { ...editorSettings, ...userSettings.editor }; - // Migrate one-dark to auto for better theme following - if (editorSettings.theme === 'one-dark') { - console.log('Migrating one-dark theme to auto'); - editorSettings.theme = 'auto'; - } - // If user has 'github' theme saved but app is in dark mode, switch to auto - const currentAppTheme = get(appTheme); - if (editorSettings.theme === 'github' && currentAppTheme === 'dark') { - console.log('User has light theme saved but app is dark - switching to auto'); - editorSettings.theme = 'auto'; - } - } - } - - // Subscribe to settings cache updates - unsubscribeSettings = settingsCache.subscribe(cached => { - if (cached && cached.editor) { - editorSettings = { ...editorSettings, ...cached.editor }; - // Migrate one-dark to auto for better theme following - if (editorSettings.theme === 'one-dark') { - console.log('Migrating cached one-dark theme to auto'); - editorSettings.theme = 'auto'; - } - // If user has 'github' theme saved but app is in dark mode, switch to auto - const currentAppTheme = get(appTheme); - if (editorSettings.theme === 'github' && currentAppTheme === 'dark') { - console.log('Cached settings have light theme but app is dark - switching to auto'); - editorSettings.theme = 'auto'; - } + + // Subscribe to editor settings store (populated by AuthInitializer) + unsubscribeSettings = editorSettingsStore.subscribe(storeSettings => { + editorSettings = storeSettings; + if (editorView) { applyEditorSettings(); } }); - - unsubscribeAuth = isAuthenticated.subscribe(async authStatus => { + + unsubscribeAuth = isAuthenticated.subscribe(authStatus => { const wasAuthenticated = authenticated; authenticated = authStatus; if (!wasAuthenticated && authenticated && editorView) { loadSavedScripts(); - // Load user settings when authenticated - const userSettings = await loadUserSettings(); - if (userSettings && userSettings.editor) { - editorSettings = { ...editorSettings, ...userSettings.editor }; - // Migrate one-dark to auto for better theme following - if (editorSettings.theme === 'one-dark') { - console.log('Migrating auth-loaded one-dark theme to auto'); - editorSettings.theme = 'auto'; - } - // If user has 'github' theme saved but app is in dark mode, switch to auto - const currentAppTheme = get(appTheme); - if (editorSettings.theme === 'github' && currentAppTheme === 'dark') { - console.log('Auth loaded settings have light theme but app is dark - switching to auto'); - editorSettings.theme = 'auto'; - } - applyEditorSettings(); - } } else if (wasAuthenticated && !authenticated) { savedScripts = []; showSavedScripts = false; diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index 10833e4..b217249 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -11,7 +11,7 @@ import { addToast } from '../stores/toastStore'; import { get } from 'svelte/store'; import { fly } from 'svelte/transition'; - import { setCachedSettings, updateCachedSetting } from '../lib/settings-cache'; + import { setUserSettings } from '../stores/userSettings'; import Spinner from '../components/Spinner.svelte'; let settings = $state(null); @@ -99,7 +99,7 @@ if (error) throw error; settings = data; - setCachedSettings(settings); + setUserSettings(settings); formData = { theme: $themeStore, @@ -145,9 +145,6 @@ if (!deepEqual(formData.editor, settings.editor)) { updates.editor = formData.editor; } - if (!deepEqual(formData.preferences, settings.preferences)) { - updates.preferences = formData.preferences; - } if (Object.keys(updates).length === 0) { addToast('No changes to save', 'info'); @@ -158,7 +155,7 @@ if (error) throw error; settings = data; - setCachedSettings(settings); + setUserSettings(settings); formData = { theme: settings.theme || 'auto', @@ -197,17 +194,11 @@ if (error) throw error; history = (data?.history || []) - .map(item => { - let displayField = item.field; - if (displayField.startsWith('preferences.')) { - displayField = displayField.replace('preferences.', ''); - } - return { - ...item, - displayField, - isRestore: item.reason && item.reason.includes('restore') - }; - }) + .map(item => ({ + ...item, + displayField: item.field, + isRestore: item.reason?.includes('restore') + })) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); historyCache = history; diff --git a/frontend/src/stores/__tests__/theme.test.ts b/frontend/src/stores/__tests__/theme.test.ts index b2b75ba..16d2cdf 100644 --- a/frontend/src/stores/__tests__/theme.test.ts +++ b/frontend/src/stores/__tests__/theme.test.ts @@ -3,7 +3,7 @@ import { get } from 'svelte/store'; // Mock the dynamic imports before importing the theme module vi.mock('../../lib/user-settings', () => ({ - saveThemeSetting: vi.fn().mockResolvedValue(true), + saveUserSettings: vi.fn().mockResolvedValue(true), })); vi.mock('../auth', () => ({ diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index 0aeb13e..e97a645 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -23,7 +23,7 @@ function getInitialTheme(): ThemeValue { const initialTheme = getInitialTheme(); const { subscribe, set: internalSet, update } = writable(initialTheme); -let saveThemeSetting: ((theme: string) => Promise) | null = null; +let saveUserSettings: ((partial: { theme?: ThemeValue }) => Promise) | null = null; let isAuthenticatedStore: import('svelte/store').Readable | null = null; if (browser) { @@ -31,7 +31,7 @@ if (browser) { import('../lib/user-settings'), import('./auth') ]).then(([userSettings, auth]) => { - saveThemeSetting = userSettings.saveThemeSetting; + saveUserSettings = userSettings.saveUserSettings; isAuthenticatedStore = auth.isAuthenticated; }); } @@ -43,8 +43,8 @@ export const theme = { if (browser) { localStorage.setItem(storageKey, value); } - if (saveThemeSetting && isAuthenticatedStore && get(isAuthenticatedStore)) { - saveThemeSetting(value); + if (saveUserSettings && isAuthenticatedStore && get(isAuthenticatedStore)) { + saveUserSettings({ theme: value }); } }, update diff --git a/frontend/src/stores/userSettings.ts b/frontend/src/stores/userSettings.ts new file mode 100644 index 0000000..f4edc49 --- /dev/null +++ b/frontend/src/stores/userSettings.ts @@ -0,0 +1,30 @@ +import { writable, derived, get } from 'svelte/store'; +import type { UserSettings, EditorSettings } from '../lib/api'; + +const DEFAULT_EDITOR_SETTINGS: EditorSettings = { + theme: 'auto', + font_size: 14, + tab_size: 4, + use_tabs: false, + word_wrap: true, + show_line_numbers: true, +}; + +export const userSettings = writable(null); + +export const editorSettings = derived(userSettings, ($userSettings) => ({ + ...DEFAULT_EDITOR_SETTINGS, + ...$userSettings?.editor +})); + +export function setUserSettings(settings: UserSettings | null): void { + userSettings.set(settings); +} + +export function updateSettings(partial: Partial): void { + userSettings.update(current => current ? { ...current, ...partial } : null); +} + +export function clearUserSettings(): void { + userSettings.set(null); +} From 082edb68bbe1c11741f3e5639d5b006dd8c1c03c Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 17:27:21 +0100 Subject: [PATCH 02/17] lang info types + updated frontend + typehints for langs except python in code editor --- backend/app/domain/execution/__init__.py | 2 + backend/app/domain/execution/models.py | 10 +- backend/app/runtime_registry.py | 7 +- backend/app/schemas_pydantic/execution.py | 15 +- backend/app/services/execution_service.py | 7 +- backend/app/settings.py | 3 +- docs/reference/openapi.json | 691 ++++++++++++++++------ frontend/package-lock.json | 57 ++ frontend/package.json | 3 + frontend/src/lib/api/index.ts | 2 +- frontend/src/lib/api/sdk.gen.ts | 24 +- frontend/src/lib/api/types.gen.ts | 506 +++++++++++----- frontend/src/routes/Editor.svelte | 108 +++- 13 files changed, 1051 insertions(+), 384 deletions(-) diff --git a/backend/app/domain/execution/__init__.py b/backend/app/domain/execution/__init__.py index 4b66b31..fb72541 100644 --- a/backend/app/domain/execution/__init__.py +++ b/backend/app/domain/execution/__init__.py @@ -7,6 +7,7 @@ from .models import ( DomainExecution, ExecutionResultDomain, + LanguageInfoDomain, ResourceLimitsDomain, ResourceUsageDomain, ) @@ -14,6 +15,7 @@ __all__ = [ "DomainExecution", "ExecutionResultDomain", + "LanguageInfoDomain", "ResourceLimitsDomain", "ResourceUsageDomain", "ExecutionServiceError", diff --git a/backend/app/domain/execution/models.py b/backend/app/domain/execution/models.py index 482e9f3..ab49ff6 100644 --- a/backend/app/domain/execution/models.py +++ b/backend/app/domain/execution/models.py @@ -64,6 +64,14 @@ def from_dict(data: dict[str, Any]) -> "ResourceUsageDomain": ) +@dataclass +class LanguageInfoDomain: + """Language runtime information.""" + + versions: list[str] + file_ext: str + + @dataclass class ResourceLimitsDomain: """K8s resource limits configuration.""" @@ -73,4 +81,4 @@ class ResourceLimitsDomain: cpu_request: str memory_request: str execution_timeout: int - supported_runtimes: dict[str, list[str]] + supported_runtimes: dict[str, LanguageInfoDomain] diff --git a/backend/app/runtime_registry.py b/backend/app/runtime_registry.py index cf4dd44..7200d61 100644 --- a/backend/app/runtime_registry.py +++ b/backend/app/runtime_registry.py @@ -1,5 +1,7 @@ from typing import NamedTuple, TypedDict +from app.domain.execution import LanguageInfoDomain + class RuntimeConfig(NamedTuple): image: str # Full Docker image reference @@ -178,4 +180,7 @@ def _make_runtime_configs() -> dict[str, dict[str, RuntimeConfig]]: RUNTIME_REGISTRY: dict[str, dict[str, RuntimeConfig]] = _make_runtime_configs() -SUPPORTED_RUNTIMES: dict[str, list[str]] = {lang: list(versions.keys()) for lang, versions in RUNTIME_REGISTRY.items()} +SUPPORTED_RUNTIMES: dict[str, LanguageInfoDomain] = { + lang: LanguageInfoDomain(versions=spec["versions"], file_ext=spec["file_ext"]) + for lang, spec in LANGUAGE_SPECS.items() +} diff --git a/backend/app/schemas_pydantic/execution.py b/backend/app/schemas_pydantic/execution.py index ad91cf9..88a6d09 100644 --- a/backend/app/schemas_pydantic/execution.py +++ b/backend/app/schemas_pydantic/execution.py @@ -77,9 +77,9 @@ def validate_runtime_supported(self) -> "ExecutionRequest": # noqa: D401 runtimes = settings.SUPPORTED_RUNTIMES or {} if self.lang not in runtimes: raise ValueError(f"Language '{self.lang}' not supported. Supported: {list(runtimes.keys())}") - versions = runtimes.get(self.lang, []) - if self.lang_version not in versions: - raise ValueError(f"Version '{self.lang_version}' not supported for {self.lang}. Supported: {versions}") + lang_info = runtimes[self.lang] + if self.lang_version not in lang_info.versions: + raise ValueError(f"Version '{self.lang_version}' not supported for {self.lang}. Supported: {lang_info.versions}") return self @@ -108,6 +108,13 @@ class ExecutionResult(BaseModel): model_config = ConfigDict(from_attributes=True) +class LanguageInfo(BaseModel): + """Language runtime information.""" + + versions: list[str] + file_ext: str + + class ResourceLimits(BaseModel): """Model for resource limits configuration.""" @@ -116,7 +123,7 @@ class ResourceLimits(BaseModel): cpu_request: str memory_request: str execution_timeout: int - supported_runtimes: dict[str, list[str]] + supported_runtimes: dict[str, LanguageInfo] class ExampleScripts(BaseModel): diff --git a/backend/app/services/execution_service.py b/backend/app/services/execution_service.py index cd0204d..9b2cebf 100644 --- a/backend/app/services/execution_service.py +++ b/backend/app/services/execution_service.py @@ -10,7 +10,12 @@ from app.db.repositories.execution_repository import ExecutionRepository from app.domain.enums.events import EventType from app.domain.enums.execution import ExecutionStatus -from app.domain.execution import DomainExecution, ExecutionResultDomain, ResourceLimitsDomain, ResourceUsageDomain +from app.domain.execution import ( + DomainExecution, + ExecutionResultDomain, + ResourceLimitsDomain, + ResourceUsageDomain, +) from app.events.core import UnifiedProducer from app.events.event_store import EventStore from app.infrastructure.kafka.events.base import BaseEvent diff --git a/backend/app/settings.py b/backend/app/settings.py index 34b0383..6e80b55 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -3,6 +3,7 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from app.domain.execution import LanguageInfoDomain from app.runtime_registry import EXAMPLE_SCRIPTS as EXEC_EXAMPLE_SCRIPTS from app.runtime_registry import SUPPORTED_RUNTIMES as RUNTIME_MATRIX @@ -37,7 +38,7 @@ class Settings(BaseSettings): K8S_POD_EXECUTION_TIMEOUT: int = 300 # in seconds K8S_POD_PRIORITY_CLASS_NAME: str | None = None - SUPPORTED_RUNTIMES: dict[str, list[str]] = Field(default_factory=lambda: RUNTIME_MATRIX) + SUPPORTED_RUNTIMES: dict[str, LanguageInfoDomain] = Field(default_factory=lambda: RUNTIME_MATRIX) EXAMPLE_SCRIPTS: dict[str, str] = Field(default_factory=lambda: EXEC_EXAMPLE_SCRIPTS) diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json index c142293..703a776 100644 --- a/docs/reference/openapi.json +++ b/docs/reference/openapi.json @@ -1160,8 +1160,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Response Liveness Api V1 Health Live Get" + "$ref": "#/components/schemas/LivenessResponse" } } } @@ -1183,8 +1182,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Response Readiness Api V1 Health Ready Get" + "$ref": "#/components/schemas/ReadinessResponse" } } } @@ -2359,171 +2357,6 @@ } } }, - "/api/v1/admin/events/{event_id}": { - "get": { - "tags": [ - "admin-events" - ], - "summary": "Get Event Detail", - "operationId": "get_event_detail_api_v1_admin_events__event_id__get", - "parameters": [ - { - "name": "event_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Event Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventDetailResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "admin-events" - ], - "summary": "Delete Event", - "operationId": "delete_event_api_v1_admin_events__event_id__delete", - "parameters": [ - { - "name": "event_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Event Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventDeleteResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/admin/events/replay": { - "post": { - "tags": [ - "admin-events" - ], - "summary": "Replay Events", - "operationId": "replay_events_api_v1_admin_events_replay_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventReplayRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventReplayResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/admin/events/replay/{session_id}/status": { - "get": { - "tags": [ - "admin-events" - ], - "summary": "Get Replay Status", - "operationId": "get_replay_status_api_v1_admin_events_replay__session_id__status_get", - "parameters": [ - { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Session Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventReplayStatusResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/api/v1/admin/events/export/csv": { "get": { "tags": [ @@ -2766,14 +2599,177 @@ "description": "End time" }, { - "name": "limit", - "in": "query", - "required": false, + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 50000, + "default": 10000, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/admin/events/{event_id}": { + "get": { + "tags": [ + "admin-events" + ], + "summary": "Get Event Detail", + "operationId": "get_event_detail_api_v1_admin_events__event_id__get", + "parameters": [ + { + "name": "event_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Event Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDetailResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "admin-events" + ], + "summary": "Delete Event", + "operationId": "delete_event_api_v1_admin_events__event_id__delete", + "parameters": [ + { + "name": "event_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Event Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDeleteResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/admin/events/replay": { + "post": { + "tags": [ + "admin-events" + ], + "summary": "Replay Events", + "operationId": "replay_events_api_v1_admin_events_replay_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventReplayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventReplayResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/admin/events/replay/{session_id}/status": { + "get": { + "tags": [ + "admin-events" + ], + "summary": "Get Replay Status", + "operationId": "get_replay_status_api_v1_admin_events_replay__session_id__status_get", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "maximum": 50000, - "default": 10000, - "title": "Limit" + "type": "string", + "title": "Session Id" } } ], @@ -2782,7 +2778,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/EventReplayStatusResponse" + } } } }, @@ -3141,8 +3139,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Response Delete User Api V1 Admin Users User Id Delete" + "$ref": "#/components/schemas/DeleteUserResponse" } } } @@ -3281,8 +3278,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Response Get User Rate Limits Api V1 Admin Users User Id Rate Limits Get" + "$ref": "#/components/schemas/UserRateLimitsResponse" } } } @@ -3333,8 +3329,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Response Update User Rate Limits Api V1 Admin Users User Id Rate Limits Put" + "$ref": "#/components/schemas/RateLimitUpdateResponse" } } } @@ -4037,7 +4032,7 @@ "sagas" ], "summary": "Get Saga Status", - "description": "Get saga status by ID.\n\nArgs:\n saga_id: The saga identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n \nReturns:\n Saga status response\n \nRaises:\n HTTPException: 404 if saga not found, 403 if access denied", + "description": "Get saga status by ID.\n\nArgs:\n saga_id: The saga identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n\nReturns:\n Saga status response\n\nRaises:\n HTTPException: 404 if saga not found, 403 if access denied", "operationId": "get_saga_status_api_v1_sagas__saga_id__get", "parameters": [ { @@ -4080,7 +4075,7 @@ "sagas" ], "summary": "Get Execution Sagas", - "description": "Get all sagas for an execution.\n\nArgs:\n execution_id: The execution identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n state: Optional state filter\n \nReturns:\n List of sagas for the execution\n \nRaises:\n HTTPException: 403 if access denied", + "description": "Get all sagas for an execution.\n\nArgs:\n execution_id: The execution identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n state: Optional state filter\n\nReturns:\n List of sagas for the execution\n\nRaises:\n HTTPException: 403 if access denied", "operationId": "get_execution_sagas_api_v1_sagas_execution__execution_id__get", "parameters": [ { @@ -4141,7 +4136,7 @@ "sagas" ], "summary": "List Sagas", - "description": "List sagas accessible by the current user.\n\nArgs:\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n state: Optional state filter\n limit: Maximum number of results\n offset: Number of results to skip\n \nReturns:\n Paginated list of sagas", + "description": "List sagas accessible by the current user.\n\nArgs:\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n state: Optional state filter\n limit: Maximum number of results\n offset: Number of results to skip\n\nReturns:\n Paginated list of sagas", "operationId": "list_sagas_api_v1_sagas__get", "parameters": [ { @@ -4216,7 +4211,7 @@ "sagas" ], "summary": "Cancel Saga", - "description": "Cancel a running saga.\n\nArgs:\n saga_id: The saga identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n \nReturns:\n Cancellation response with success status\n \nRaises:\n HTTPException: 404 if not found, 403 if denied, 400 if invalid state", + "description": "Cancel a running saga.\n\nArgs:\n saga_id: The saga identifier\n request: FastAPI request object\n saga_service: Saga service from DI\n auth_service: Auth service from DI\n\nReturns:\n Cancellation response with success status\n\nRaises:\n HTTPException: 404 if not found, 403 if denied, 400 if invalid state", "operationId": "cancel_saga_api_v1_sagas__saga_id__cancel_post", "parameters": [ { @@ -4955,6 +4950,28 @@ "title": "DeleteResponse", "description": "Model for execution deletion response." }, + "DeleteUserResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "deleted_counts": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Deleted Counts" + } + }, + "type": "object", + "required": [ + "message", + "deleted_counts" + ], + "title": "DeleteUserResponse", + "description": "Response model for user deletion." + }, "DerivedCounts": { "properties": { "succeeded": { @@ -6363,6 +6380,55 @@ "type": "object", "title": "HTTPValidationError" }, + "LanguageInfo": { + "properties": { + "versions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Versions" + }, + "file_ext": { + "type": "string", + "title": "File Ext" + } + }, + "type": "object", + "required": [ + "versions", + "file_ext" + ], + "title": "LanguageInfo", + "description": "Language runtime information." + }, + "LivenessResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status", + "description": "Health status" + }, + "uptime_seconds": { + "type": "integer", + "title": "Uptime Seconds", + "description": "Server uptime in seconds" + }, + "timestamp": { + "type": "string", + "title": "Timestamp", + "description": "ISO timestamp of health check" + } + }, + "type": "object", + "required": [ + "status", + "uptime_seconds", + "timestamp" + ], + "title": "LivenessResponse", + "description": "Response model for liveness probe." + }, "LoginResponse": { "properties": { "message": { @@ -6924,6 +6990,55 @@ ], "title": "RateLimitRule" }, + "RateLimitRuleResponse": { + "properties": { + "endpoint_pattern": { + "type": "string", + "title": "Endpoint Pattern" + }, + "group": { + "type": "string", + "title": "Group" + }, + "requests": { + "type": "integer", + "title": "Requests" + }, + "window_seconds": { + "type": "integer", + "title": "Window Seconds" + }, + "algorithm": { + "type": "string", + "title": "Algorithm" + }, + "burst_multiplier": { + "type": "number", + "title": "Burst Multiplier", + "default": 1.5 + }, + "priority": { + "type": "integer", + "title": "Priority", + "default": 0 + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "required": [ + "endpoint_pattern", + "group", + "requests", + "window_seconds", + "algorithm" + ], + "title": "RateLimitRuleResponse", + "description": "Response model for rate limit rule." + }, "RateLimitSummary": { "properties": { "bypass_rate_limit": { @@ -6963,6 +7078,50 @@ "type": "object", "title": "RateLimitSummary" }, + "RateLimitUpdateResponse": { + "properties": { + "user_id": { + "type": "string", + "title": "User Id" + }, + "updated": { + "type": "boolean", + "title": "Updated" + }, + "config": { + "$ref": "#/components/schemas/UserRateLimitConfigResponse" + } + }, + "type": "object", + "required": [ + "user_id", + "updated", + "config" + ], + "title": "RateLimitUpdateResponse", + "description": "Response model for rate limit update." + }, + "ReadinessResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status", + "description": "Readiness status" + }, + "uptime_seconds": { + "type": "integer", + "title": "Uptime Seconds", + "description": "Server uptime in seconds" + } + }, + "type": "object", + "required": [ + "status", + "uptime_seconds" + ], + "title": "ReadinessResponse", + "description": "Response model for readiness probe." + }, "ReplayAggregateResponse": { "properties": { "dry_run": { @@ -7555,10 +7714,7 @@ }, "supported_runtimes": { "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" + "$ref": "#/components/schemas/LanguageInfo" }, "type": "object", "title": "Supported Runtimes" @@ -7766,8 +7922,7 @@ "description": "Maximum connections allowed per user" }, "shutdown": { - "type": "object", - "title": "Shutdown", + "$ref": "#/components/schemas/ShutdownStatusResponse", "description": "Shutdown status information" }, "timestamp": { @@ -8256,6 +8411,57 @@ "title": "SettingsHistoryResponse", "description": "Response model for settings history" }, + "ShutdownStatusResponse": { + "properties": { + "phase": { + "type": "string", + "title": "Phase", + "description": "Current shutdown phase" + }, + "initiated": { + "type": "boolean", + "title": "Initiated", + "description": "Whether shutdown has been initiated" + }, + "complete": { + "type": "boolean", + "title": "Complete", + "description": "Whether shutdown is complete" + }, + "active_connections": { + "type": "integer", + "title": "Active Connections", + "description": "Number of active connections" + }, + "draining_connections": { + "type": "integer", + "title": "Draining Connections", + "description": "Number of connections being drained" + }, + "duration": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Duration", + "description": "Duration of shutdown in seconds" + } + }, + "type": "object", + "required": [ + "phase", + "initiated", + "complete", + "active_connections", + "draining_connections" + ], + "title": "ShutdownStatusResponse", + "description": "Response model for shutdown status." + }, "SortOrder": { "type": "string", "enum": [ @@ -8576,6 +8782,105 @@ ], "title": "UserRateLimit" }, + "UserRateLimitConfigResponse": { + "properties": { + "user_id": { + "type": "string", + "title": "User Id" + }, + "bypass_rate_limit": { + "type": "boolean", + "title": "Bypass Rate Limit" + }, + "global_multiplier": { + "type": "number", + "title": "Global Multiplier" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/RateLimitRuleResponse" + }, + "type": "array", + "title": "Rules" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + } + }, + "type": "object", + "required": [ + "user_id", + "bypass_rate_limit", + "global_multiplier", + "rules" + ], + "title": "UserRateLimitConfigResponse", + "description": "Response model for user rate limit config." + }, + "UserRateLimitsResponse": { + "properties": { + "user_id": { + "type": "string", + "title": "User Id" + }, + "rate_limit_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserRateLimitConfigResponse" + }, + { + "type": "null" + } + ] + }, + "current_usage": { + "additionalProperties": { + "type": "object" + }, + "type": "object", + "title": "Current Usage" + } + }, + "type": "object", + "required": [ + "user_id", + "current_usage" + ], + "title": "UserRateLimitsResponse", + "description": "Response model for user rate limits with usage stats." + }, "UserResponse": { "properties": { "username": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 241d03d..792e0c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,11 @@ "@babel/runtime": "^7.27.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.7.0", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-python": "^6.1.6", "@codemirror/language": "^6.10.2", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/state": "^6.4.1", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.34.1", @@ -208,6 +211,32 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, "node_modules/@codemirror/lang-python": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", @@ -233,6 +262,14 @@ "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", @@ -969,6 +1006,16 @@ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==" }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -977,6 +1024,16 @@ "@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 906e875..3f30510 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,8 +18,11 @@ "@babel/runtime": "^7.27.6", "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.7.0", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-python": "^6.1.6", "@codemirror/language": "^6.10.2", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/state": "^6.4.1", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.34.1", diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index c9a69c5..917b89a 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { aggregateEventsApiV1EventsAggregatePost, browseEventsApiV1AdminEventsBrowsePost, cancelExecutionApiV1ExecutionIdCancelPost, cancelReplaySessionApiV1ReplaySessionsSessionIdCancelPost, cancelSagaApiV1SagasSagaIdCancelPost, cleanupOldSessionsApiV1ReplayCleanupPost, createExecutionApiV1ExecutePost, createReplaySessionApiV1ReplaySessionsPost, createSavedScriptApiV1ScriptsPost, createUserApiV1AdminUsersPost, deleteEventApiV1AdminEventsEventIdDelete, deleteEventApiV1EventsEventIdDelete, deleteExecutionApiV1ExecutionIdDelete, deleteNotificationApiV1NotificationsNotificationIdDelete, deleteSavedScriptApiV1ScriptsScriptIdDelete, deleteUserApiV1AdminUsersUserIdDelete, discardDlqMessageApiV1DlqMessagesEventIdDelete, executionEventsApiV1EventsExecutionsExecutionIdGet, exportEventsCsvApiV1AdminEventsExportCsvGet, exportEventsJsonApiV1AdminEventsExportJsonGet, getCurrentRequestEventsApiV1EventsCurrentRequestGet, getCurrentUserProfileApiV1AuthMeGet, getDlqMessageApiV1DlqMessagesEventIdGet, getDlqMessagesApiV1DlqMessagesGet, getDlqStatisticsApiV1DlqStatsGet, getDlqTopicsApiV1DlqTopicsGet, getEventApiV1EventsEventIdGet, getEventDetailApiV1AdminEventsEventIdGet, getEventsByCorrelationApiV1EventsCorrelationCorrelationIdGet, getEventStatisticsApiV1EventsStatisticsGet, getEventStatsApiV1AdminEventsStatsGet, getExampleScriptsApiV1ExampleScriptsGet, getExecutionEventsApiV1EventsExecutionsExecutionIdEventsGet, getExecutionEventsApiV1ExecutionsExecutionIdEventsGet, getExecutionSagasApiV1SagasExecutionExecutionIdGet, getK8sResourceLimitsApiV1K8sLimitsGet, getNotificationsApiV1NotificationsGet, getReplaySessionApiV1ReplaySessionsSessionIdGet, getReplayStatusApiV1AdminEventsReplaySessionIdStatusGet, getResultApiV1ResultExecutionIdGet, getSagaStatusApiV1SagasSagaIdGet, getSavedScriptApiV1ScriptsScriptIdGet, getSettingsHistoryApiV1UserSettingsHistoryGet, getSubscriptionsApiV1NotificationsSubscriptionsGet, getSystemSettingsApiV1AdminSettingsGet, getUnreadCountApiV1NotificationsUnreadCountGet, getUserApiV1AdminUsersUserIdGet, getUserEventsApiV1EventsUserGet, getUserExecutionsApiV1UserExecutionsGet, getUserOverviewApiV1AdminUsersUserIdOverviewGet, getUserRateLimitsApiV1AdminUsersUserIdRateLimitsGet, getUserSettingsApiV1UserSettingsGet, listEventTypesApiV1EventsTypesListGet, listReplaySessionsApiV1ReplaySessionsGet, listSagasApiV1SagasGet, listSavedScriptsApiV1ScriptsGet, listUsersApiV1AdminUsersGet, livenessApiV1HealthLiveGet, loginApiV1AuthLoginPost, logoutApiV1AuthLogoutPost, markAllReadApiV1NotificationsMarkAllReadPost, markNotificationReadApiV1NotificationsNotificationIdReadPut, notificationStreamApiV1EventsNotificationsStreamGet, type Options, pauseReplaySessionApiV1ReplaySessionsSessionIdPausePost, publishCustomEventApiV1EventsPublishPost, queryEventsApiV1EventsQueryPost, readinessApiV1HealthReadyGet, receiveGrafanaAlertsApiV1AlertsGrafanaPost, registerApiV1AuthRegisterPost, replayAggregateEventsApiV1EventsReplayAggregateIdPost, replayEventsApiV1AdminEventsReplayPost, resetSystemSettingsApiV1AdminSettingsResetPost, resetUserPasswordApiV1AdminUsersUserIdResetPasswordPost, resetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPost, restoreSettingsApiV1UserSettingsRestorePost, resumeReplaySessionApiV1ReplaySessionsSessionIdResumePost, retryDlqMessagesApiV1DlqRetryPost, retryExecutionApiV1ExecutionIdRetryPost, setRetryPolicyApiV1DlqRetryPolicyPost, sseHealthApiV1EventsHealthGet, startReplaySessionApiV1ReplaySessionsSessionIdStartPost, testGrafanaAlertEndpointApiV1AlertsGrafanaTestGet, updateCustomSettingApiV1UserSettingsCustomKeyPut, updateEditorSettingsApiV1UserSettingsEditorPut, updateNotificationSettingsApiV1UserSettingsNotificationsPut, updateSavedScriptApiV1ScriptsScriptIdPut, updateSubscriptionApiV1NotificationsSubscriptionsChannelPut, updateSystemSettingsApiV1AdminSettingsPut, updateThemeApiV1UserSettingsThemePut, updateUserApiV1AdminUsersUserIdPut, updateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPut, updateUserSettingsApiV1UserSettingsPut, verifyTokenApiV1AuthVerifyTokenGet } from './sdk.gen'; -export type { AdminUserOverview, AggregateEventsApiV1EventsAggregatePostData, AggregateEventsApiV1EventsAggregatePostError, AggregateEventsApiV1EventsAggregatePostErrors, AggregateEventsApiV1EventsAggregatePostResponse, AggregateEventsApiV1EventsAggregatePostResponses, AlertResponse, BodyLoginApiV1AuthLoginPost, BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostError, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponse, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionIdCancelPostData, CancelExecutionApiV1ExecutionIdCancelPostError, CancelExecutionApiV1ExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionIdCancelPostResponse, CancelExecutionApiV1ExecutionIdCancelPostResponses, CancelExecutionRequest, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostError, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponse, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelResponse, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostError, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponse, CancelSagaApiV1SagasSagaIdCancelPostResponses, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostError, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponse, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CleanupResponse, ClientOptions, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostError, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponse, CreateExecutionApiV1ExecutePostResponses, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostError, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponse, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostError, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponse, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostError, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponse, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteError, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponse, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteEventApiV1EventsEventIdDeleteData, DeleteEventApiV1EventsEventIdDeleteError, DeleteEventApiV1EventsEventIdDeleteErrors, DeleteEventApiV1EventsEventIdDeleteResponse, DeleteEventApiV1EventsEventIdDeleteResponses, DeleteEventResponse, DeleteExecutionApiV1ExecutionIdDeleteData, DeleteExecutionApiV1ExecutionIdDeleteError, DeleteExecutionApiV1ExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionIdDeleteResponse, DeleteExecutionApiV1ExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteError, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponse, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteNotificationResponse, DeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteError, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteError, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponse, DeleteUserApiV1AdminUsersUserIdDeleteResponses, DerivedCounts, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteData, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteError, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteErrors, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteResponse, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteResponses, DlqBatchRetryResponse, DlqMessageDetail, DlqMessageResponse, DlqMessagesResponse, DlqMessageStatus, DlqStats, DlqTopicSummaryResponse, EditorSettings, EndpointGroup, ErrorType, EventAggregationRequest, EventBrowseRequest, EventBrowseResponse, EventDeleteResponse, EventDetailResponse, EventFilter, EventFilterRequest, EventListResponse, EventReplayRequest, EventReplayResponse, EventReplayStatusResponse, EventResponse, EventStatistics, EventStatsResponse, EventType, ExampleScripts, ExecutionEventResponse, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetError, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExecutionLimitsSchema, ExecutionListResponse, ExecutionRequest, ExecutionResponse, ExecutionResult, ExecutionStatus, ExportEventsCsvApiV1AdminEventsExportCsvGetData, ExportEventsCsvApiV1AdminEventsExportCsvGetError, ExportEventsCsvApiV1AdminEventsExportCsvGetErrors, ExportEventsCsvApiV1AdminEventsExportCsvGetResponses, ExportEventsJsonApiV1AdminEventsExportJsonGetData, ExportEventsJsonApiV1AdminEventsExportJsonGetError, ExportEventsJsonApiV1AdminEventsExportJsonGetErrors, ExportEventsJsonApiV1AdminEventsExportJsonGetResponses, GetCurrentRequestEventsApiV1EventsCurrentRequestGetData, GetCurrentRequestEventsApiV1EventsCurrentRequestGetError, GetCurrentRequestEventsApiV1EventsCurrentRequestGetErrors, GetCurrentRequestEventsApiV1EventsCurrentRequestGetResponse, GetCurrentRequestEventsApiV1EventsCurrentRequestGetResponses, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponse, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDlqMessageApiV1DlqMessagesEventIdGetData, GetDlqMessageApiV1DlqMessagesEventIdGetError, GetDlqMessageApiV1DlqMessagesEventIdGetErrors, GetDlqMessageApiV1DlqMessagesEventIdGetResponse, GetDlqMessageApiV1DlqMessagesEventIdGetResponses, GetDlqMessagesApiV1DlqMessagesGetData, GetDlqMessagesApiV1DlqMessagesGetError, GetDlqMessagesApiV1DlqMessagesGetErrors, GetDlqMessagesApiV1DlqMessagesGetResponse, GetDlqMessagesApiV1DlqMessagesGetResponses, GetDlqStatisticsApiV1DlqStatsGetData, GetDlqStatisticsApiV1DlqStatsGetResponse, GetDlqStatisticsApiV1DlqStatsGetResponses, GetDlqTopicsApiV1DlqTopicsGetData, GetDlqTopicsApiV1DlqTopicsGetResponse, GetDlqTopicsApiV1DlqTopicsGetResponses, GetEventApiV1EventsEventIdGetData, GetEventApiV1EventsEventIdGetError, GetEventApiV1EventsEventIdGetErrors, GetEventApiV1EventsEventIdGetResponse, GetEventApiV1EventsEventIdGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetError, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponse, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetData, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetError, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetErrors, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetResponse, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetResponses, GetEventStatisticsApiV1EventsStatisticsGetData, GetEventStatisticsApiV1EventsStatisticsGetError, GetEventStatisticsApiV1EventsStatisticsGetErrors, GetEventStatisticsApiV1EventsStatisticsGetResponse, GetEventStatisticsApiV1EventsStatisticsGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetError, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponse, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponse, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetError, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponse, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponse, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetError, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponse, GetNotificationsApiV1NotificationsGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetError, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponse, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, GetResultApiV1ResultExecutionIdGetData, GetResultApiV1ResultExecutionIdGetError, GetResultApiV1ResultExecutionIdGetErrors, GetResultApiV1ResultExecutionIdGetResponse, GetResultApiV1ResultExecutionIdGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetError, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponse, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetError, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponse, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetError, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponse, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponse, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetResponse, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponse, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetError, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponse, GetUserApiV1AdminUsersUserIdGetResponses, GetUserEventsApiV1EventsUserGetData, GetUserEventsApiV1EventsUserGetError, GetUserEventsApiV1EventsUserGetErrors, GetUserEventsApiV1EventsUserGetResponse, GetUserEventsApiV1EventsUserGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetError, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponse, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetError, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponse, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetData, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetError, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetErrors, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponse, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponse, GetUserSettingsApiV1UserSettingsGetResponses, GrafanaAlertItem, GrafanaWebhook, HttpValidationError, ListEventTypesApiV1EventsTypesListGetData, ListEventTypesApiV1EventsTypesListGetResponse, ListEventTypesApiV1EventsTypesListGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetError, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponse, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetError, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponse, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponse, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetError, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponse, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponse, LivenessApiV1HealthLiveGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginResponse, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponse, LogoutApiV1AuthLogoutPostResponses, ManualRetryRequest, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponse, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutError, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponse, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, MessageResponse, MonitoringSettingsSchema, NotificationChannel, NotificationListResponse, NotificationResponse, NotificationSettings, NotificationSeverity, NotificationStatus, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponses, NotificationSubscription, PasswordResetRequest, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostError, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponse, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, PublishCustomEventApiV1EventsPublishPostData, PublishCustomEventApiV1EventsPublishPostError, PublishCustomEventApiV1EventsPublishPostErrors, PublishCustomEventApiV1EventsPublishPostResponse, PublishCustomEventApiV1EventsPublishPostResponses, PublishEventRequest, PublishEventResponse, QueryEventsApiV1EventsQueryPostData, QueryEventsApiV1EventsQueryPostError, QueryEventsApiV1EventsQueryPostErrors, QueryEventsApiV1EventsQueryPostResponse, QueryEventsApiV1EventsQueryPostResponses, RateLimitAlgorithm, RateLimitRule, RateLimitSummary, ReadinessApiV1HealthReadyGetData, ReadinessApiV1HealthReadyGetResponse, ReadinessApiV1HealthReadyGetResponses, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostData, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostError, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostErrors, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostResponse, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostResponses, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostError, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponse, RegisterApiV1AuthRegisterPostResponses, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostData, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostError, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostErrors, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostResponse, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostResponses, ReplayAggregateResponse, ReplayConfigSchema, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostError, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponse, ReplayEventsApiV1AdminEventsReplayPostResponses, ReplayFilterSchema, ReplayRequest, ReplayResponse, ReplaySession, ReplayStatus, ReplayTarget, ReplayType, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostResponse, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostError, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponse, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostData, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostError, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostErrors, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostResponse, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostResponses, ResourceLimits, ResourceUsage, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostError, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponse, RestoreSettingsApiV1UserSettingsRestorePostResponses, RestoreSettingsRequest, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostError, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponse, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryDlqMessagesApiV1DlqRetryPostData, RetryDlqMessagesApiV1DlqRetryPostError, RetryDlqMessagesApiV1DlqRetryPostErrors, RetryDlqMessagesApiV1DlqRetryPostResponse, RetryDlqMessagesApiV1DlqRetryPostResponses, RetryExecutionApiV1ExecutionIdRetryPostData, RetryExecutionApiV1ExecutionIdRetryPostError, RetryExecutionApiV1ExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionIdRetryPostResponse, RetryExecutionApiV1ExecutionIdRetryPostResponses, RetryExecutionRequest, RetryPolicyRequest, RetryStrategy, SagaCancellationResponse, SagaListResponse, SagaState, SagaStatusResponse, SavedScriptCreateRequest, SavedScriptResponse, SecuritySettingsSchema, SessionSummary, SetRetryPolicyApiV1DlqRetryPolicyPostData, SetRetryPolicyApiV1DlqRetryPolicyPostError, SetRetryPolicyApiV1DlqRetryPolicyPostErrors, SetRetryPolicyApiV1DlqRetryPolicyPostResponse, SetRetryPolicyApiV1DlqRetryPolicyPostResponses, SettingsHistoryEntry, SettingsHistoryResponse, SortOrder, SseHealthApiV1EventsHealthGetData, SseHealthApiV1EventsHealthGetResponse, SseHealthApiV1EventsHealthGetResponses, SseHealthResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostError, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, SubscriptionsResponse, SubscriptionUpdate, SystemSettings, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetData, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetResponse, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetResponses, Theme, ThemeUpdateRequest, TokenValidationResponse, UnreadCountResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutError, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutError, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponse, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutError, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponse, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutError, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponse, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutError, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponse, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutError, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponse, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutError, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponse, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutError, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponse, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutData, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutError, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutErrors, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponse, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutError, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponse, UpdateUserSettingsApiV1UserSettingsPutResponses, UserCreate, UserListResponse, UserRateLimit, UserResponse, UserRole, UserSettings, UserSettingsUpdate, UserUpdate, ValidationError, VerifyTokenApiV1AuthVerifyTokenGetData, VerifyTokenApiV1AuthVerifyTokenGetResponse, VerifyTokenApiV1AuthVerifyTokenGetResponses } from './types.gen'; +export type { AdminUserOverview, AggregateEventsApiV1EventsAggregatePostData, AggregateEventsApiV1EventsAggregatePostError, AggregateEventsApiV1EventsAggregatePostErrors, AggregateEventsApiV1EventsAggregatePostResponse, AggregateEventsApiV1EventsAggregatePostResponses, AlertResponse, BodyLoginApiV1AuthLoginPost, BrowseEventsApiV1AdminEventsBrowsePostData, BrowseEventsApiV1AdminEventsBrowsePostError, BrowseEventsApiV1AdminEventsBrowsePostErrors, BrowseEventsApiV1AdminEventsBrowsePostResponse, BrowseEventsApiV1AdminEventsBrowsePostResponses, CancelExecutionApiV1ExecutionIdCancelPostData, CancelExecutionApiV1ExecutionIdCancelPostError, CancelExecutionApiV1ExecutionIdCancelPostErrors, CancelExecutionApiV1ExecutionIdCancelPostResponse, CancelExecutionApiV1ExecutionIdCancelPostResponses, CancelExecutionRequest, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostData, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostError, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostErrors, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponse, CancelReplaySessionApiV1ReplaySessionsSessionIdCancelPostResponses, CancelResponse, CancelSagaApiV1SagasSagaIdCancelPostData, CancelSagaApiV1SagasSagaIdCancelPostError, CancelSagaApiV1SagasSagaIdCancelPostErrors, CancelSagaApiV1SagasSagaIdCancelPostResponse, CancelSagaApiV1SagasSagaIdCancelPostResponses, CleanupOldSessionsApiV1ReplayCleanupPostData, CleanupOldSessionsApiV1ReplayCleanupPostError, CleanupOldSessionsApiV1ReplayCleanupPostErrors, CleanupOldSessionsApiV1ReplayCleanupPostResponse, CleanupOldSessionsApiV1ReplayCleanupPostResponses, CleanupResponse, ClientOptions, CreateExecutionApiV1ExecutePostData, CreateExecutionApiV1ExecutePostError, CreateExecutionApiV1ExecutePostErrors, CreateExecutionApiV1ExecutePostResponse, CreateExecutionApiV1ExecutePostResponses, CreateReplaySessionApiV1ReplaySessionsPostData, CreateReplaySessionApiV1ReplaySessionsPostError, CreateReplaySessionApiV1ReplaySessionsPostErrors, CreateReplaySessionApiV1ReplaySessionsPostResponse, CreateReplaySessionApiV1ReplaySessionsPostResponses, CreateSavedScriptApiV1ScriptsPostData, CreateSavedScriptApiV1ScriptsPostError, CreateSavedScriptApiV1ScriptsPostErrors, CreateSavedScriptApiV1ScriptsPostResponse, CreateSavedScriptApiV1ScriptsPostResponses, CreateUserApiV1AdminUsersPostData, CreateUserApiV1AdminUsersPostError, CreateUserApiV1AdminUsersPostErrors, CreateUserApiV1AdminUsersPostResponse, CreateUserApiV1AdminUsersPostResponses, DeleteEventApiV1AdminEventsEventIdDeleteData, DeleteEventApiV1AdminEventsEventIdDeleteError, DeleteEventApiV1AdminEventsEventIdDeleteErrors, DeleteEventApiV1AdminEventsEventIdDeleteResponse, DeleteEventApiV1AdminEventsEventIdDeleteResponses, DeleteEventApiV1EventsEventIdDeleteData, DeleteEventApiV1EventsEventIdDeleteError, DeleteEventApiV1EventsEventIdDeleteErrors, DeleteEventApiV1EventsEventIdDeleteResponse, DeleteEventApiV1EventsEventIdDeleteResponses, DeleteEventResponse, DeleteExecutionApiV1ExecutionIdDeleteData, DeleteExecutionApiV1ExecutionIdDeleteError, DeleteExecutionApiV1ExecutionIdDeleteErrors, DeleteExecutionApiV1ExecutionIdDeleteResponse, DeleteExecutionApiV1ExecutionIdDeleteResponses, DeleteNotificationApiV1NotificationsNotificationIdDeleteData, DeleteNotificationApiV1NotificationsNotificationIdDeleteError, DeleteNotificationApiV1NotificationsNotificationIdDeleteErrors, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponse, DeleteNotificationApiV1NotificationsNotificationIdDeleteResponses, DeleteNotificationResponse, DeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteData, DeleteSavedScriptApiV1ScriptsScriptIdDeleteError, DeleteSavedScriptApiV1ScriptsScriptIdDeleteErrors, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponse, DeleteSavedScriptApiV1ScriptsScriptIdDeleteResponses, DeleteUserApiV1AdminUsersUserIdDeleteData, DeleteUserApiV1AdminUsersUserIdDeleteError, DeleteUserApiV1AdminUsersUserIdDeleteErrors, DeleteUserApiV1AdminUsersUserIdDeleteResponse, DeleteUserApiV1AdminUsersUserIdDeleteResponses, DeleteUserResponse, DerivedCounts, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteData, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteError, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteErrors, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteResponse, DiscardDlqMessageApiV1DlqMessagesEventIdDeleteResponses, DlqBatchRetryResponse, DlqMessageDetail, DlqMessageResponse, DlqMessagesResponse, DlqMessageStatus, DlqStats, DlqTopicSummaryResponse, EditorSettings, EndpointGroup, ErrorType, EventAggregationRequest, EventBrowseRequest, EventBrowseResponse, EventDeleteResponse, EventDetailResponse, EventFilter, EventFilterRequest, EventListResponse, EventReplayRequest, EventReplayResponse, EventReplayStatusResponse, EventResponse, EventStatistics, EventStatsResponse, EventType, ExampleScripts, ExecutionEventResponse, ExecutionEventsApiV1EventsExecutionsExecutionIdGetData, ExecutionEventsApiV1EventsExecutionsExecutionIdGetError, ExecutionEventsApiV1EventsExecutionsExecutionIdGetErrors, ExecutionEventsApiV1EventsExecutionsExecutionIdGetResponses, ExecutionLimitsSchema, ExecutionListResponse, ExecutionRequest, ExecutionResponse, ExecutionResult, ExecutionStatus, ExportEventsCsvApiV1AdminEventsExportCsvGetData, ExportEventsCsvApiV1AdminEventsExportCsvGetError, ExportEventsCsvApiV1AdminEventsExportCsvGetErrors, ExportEventsCsvApiV1AdminEventsExportCsvGetResponses, ExportEventsJsonApiV1AdminEventsExportJsonGetData, ExportEventsJsonApiV1AdminEventsExportJsonGetError, ExportEventsJsonApiV1AdminEventsExportJsonGetErrors, ExportEventsJsonApiV1AdminEventsExportJsonGetResponses, GetCurrentRequestEventsApiV1EventsCurrentRequestGetData, GetCurrentRequestEventsApiV1EventsCurrentRequestGetError, GetCurrentRequestEventsApiV1EventsCurrentRequestGetErrors, GetCurrentRequestEventsApiV1EventsCurrentRequestGetResponse, GetCurrentRequestEventsApiV1EventsCurrentRequestGetResponses, GetCurrentUserProfileApiV1AuthMeGetData, GetCurrentUserProfileApiV1AuthMeGetResponse, GetCurrentUserProfileApiV1AuthMeGetResponses, GetDlqMessageApiV1DlqMessagesEventIdGetData, GetDlqMessageApiV1DlqMessagesEventIdGetError, GetDlqMessageApiV1DlqMessagesEventIdGetErrors, GetDlqMessageApiV1DlqMessagesEventIdGetResponse, GetDlqMessageApiV1DlqMessagesEventIdGetResponses, GetDlqMessagesApiV1DlqMessagesGetData, GetDlqMessagesApiV1DlqMessagesGetError, GetDlqMessagesApiV1DlqMessagesGetErrors, GetDlqMessagesApiV1DlqMessagesGetResponse, GetDlqMessagesApiV1DlqMessagesGetResponses, GetDlqStatisticsApiV1DlqStatsGetData, GetDlqStatisticsApiV1DlqStatsGetResponse, GetDlqStatisticsApiV1DlqStatsGetResponses, GetDlqTopicsApiV1DlqTopicsGetData, GetDlqTopicsApiV1DlqTopicsGetResponse, GetDlqTopicsApiV1DlqTopicsGetResponses, GetEventApiV1EventsEventIdGetData, GetEventApiV1EventsEventIdGetError, GetEventApiV1EventsEventIdGetErrors, GetEventApiV1EventsEventIdGetResponse, GetEventApiV1EventsEventIdGetResponses, GetEventDetailApiV1AdminEventsEventIdGetData, GetEventDetailApiV1AdminEventsEventIdGetError, GetEventDetailApiV1AdminEventsEventIdGetErrors, GetEventDetailApiV1AdminEventsEventIdGetResponse, GetEventDetailApiV1AdminEventsEventIdGetResponses, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetData, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetError, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetErrors, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetResponse, GetEventsByCorrelationApiV1EventsCorrelationCorrelationIdGetResponses, GetEventStatisticsApiV1EventsStatisticsGetData, GetEventStatisticsApiV1EventsStatisticsGetError, GetEventStatisticsApiV1EventsStatisticsGetErrors, GetEventStatisticsApiV1EventsStatisticsGetResponse, GetEventStatisticsApiV1EventsStatisticsGetResponses, GetEventStatsApiV1AdminEventsStatsGetData, GetEventStatsApiV1AdminEventsStatsGetError, GetEventStatsApiV1AdminEventsStatsGetErrors, GetEventStatsApiV1AdminEventsStatsGetResponse, GetEventStatsApiV1AdminEventsStatsGetResponses, GetExampleScriptsApiV1ExampleScriptsGetData, GetExampleScriptsApiV1ExampleScriptsGetResponse, GetExampleScriptsApiV1ExampleScriptsGetResponses, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1EventsExecutionsExecutionIdEventsGetResponses, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetData, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetError, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetErrors, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponse, GetExecutionEventsApiV1ExecutionsExecutionIdEventsGetResponses, GetExecutionSagasApiV1SagasExecutionExecutionIdGetData, GetExecutionSagasApiV1SagasExecutionExecutionIdGetError, GetExecutionSagasApiV1SagasExecutionExecutionIdGetErrors, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponse, GetExecutionSagasApiV1SagasExecutionExecutionIdGetResponses, GetK8sResourceLimitsApiV1K8sLimitsGetData, GetK8sResourceLimitsApiV1K8sLimitsGetResponse, GetK8sResourceLimitsApiV1K8sLimitsGetResponses, GetNotificationsApiV1NotificationsGetData, GetNotificationsApiV1NotificationsGetError, GetNotificationsApiV1NotificationsGetErrors, GetNotificationsApiV1NotificationsGetResponse, GetNotificationsApiV1NotificationsGetResponses, GetReplaySessionApiV1ReplaySessionsSessionIdGetData, GetReplaySessionApiV1ReplaySessionsSessionIdGetError, GetReplaySessionApiV1ReplaySessionsSessionIdGetErrors, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponse, GetReplaySessionApiV1ReplaySessionsSessionIdGetResponses, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse, GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses, GetResultApiV1ResultExecutionIdGetData, GetResultApiV1ResultExecutionIdGetError, GetResultApiV1ResultExecutionIdGetErrors, GetResultApiV1ResultExecutionIdGetResponse, GetResultApiV1ResultExecutionIdGetResponses, GetSagaStatusApiV1SagasSagaIdGetData, GetSagaStatusApiV1SagasSagaIdGetError, GetSagaStatusApiV1SagasSagaIdGetErrors, GetSagaStatusApiV1SagasSagaIdGetResponse, GetSagaStatusApiV1SagasSagaIdGetResponses, GetSavedScriptApiV1ScriptsScriptIdGetData, GetSavedScriptApiV1ScriptsScriptIdGetError, GetSavedScriptApiV1ScriptsScriptIdGetErrors, GetSavedScriptApiV1ScriptsScriptIdGetResponse, GetSavedScriptApiV1ScriptsScriptIdGetResponses, GetSettingsHistoryApiV1UserSettingsHistoryGetData, GetSettingsHistoryApiV1UserSettingsHistoryGetError, GetSettingsHistoryApiV1UserSettingsHistoryGetErrors, GetSettingsHistoryApiV1UserSettingsHistoryGetResponse, GetSettingsHistoryApiV1UserSettingsHistoryGetResponses, GetSubscriptionsApiV1NotificationsSubscriptionsGetData, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponse, GetSubscriptionsApiV1NotificationsSubscriptionsGetResponses, GetSystemSettingsApiV1AdminSettingsGetData, GetSystemSettingsApiV1AdminSettingsGetResponse, GetSystemSettingsApiV1AdminSettingsGetResponses, GetUnreadCountApiV1NotificationsUnreadCountGetData, GetUnreadCountApiV1NotificationsUnreadCountGetResponse, GetUnreadCountApiV1NotificationsUnreadCountGetResponses, GetUserApiV1AdminUsersUserIdGetData, GetUserApiV1AdminUsersUserIdGetError, GetUserApiV1AdminUsersUserIdGetErrors, GetUserApiV1AdminUsersUserIdGetResponse, GetUserApiV1AdminUsersUserIdGetResponses, GetUserEventsApiV1EventsUserGetData, GetUserEventsApiV1EventsUserGetError, GetUserEventsApiV1EventsUserGetErrors, GetUserEventsApiV1EventsUserGetResponse, GetUserEventsApiV1EventsUserGetResponses, GetUserExecutionsApiV1UserExecutionsGetData, GetUserExecutionsApiV1UserExecutionsGetError, GetUserExecutionsApiV1UserExecutionsGetErrors, GetUserExecutionsApiV1UserExecutionsGetResponse, GetUserExecutionsApiV1UserExecutionsGetResponses, GetUserOverviewApiV1AdminUsersUserIdOverviewGetData, GetUserOverviewApiV1AdminUsersUserIdOverviewGetError, GetUserOverviewApiV1AdminUsersUserIdOverviewGetErrors, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponse, GetUserOverviewApiV1AdminUsersUserIdOverviewGetResponses, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetData, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetError, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetErrors, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponse, GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponses, GetUserSettingsApiV1UserSettingsGetData, GetUserSettingsApiV1UserSettingsGetResponse, GetUserSettingsApiV1UserSettingsGetResponses, GrafanaAlertItem, GrafanaWebhook, HttpValidationError, LanguageInfo, ListEventTypesApiV1EventsTypesListGetData, ListEventTypesApiV1EventsTypesListGetResponse, ListEventTypesApiV1EventsTypesListGetResponses, ListReplaySessionsApiV1ReplaySessionsGetData, ListReplaySessionsApiV1ReplaySessionsGetError, ListReplaySessionsApiV1ReplaySessionsGetErrors, ListReplaySessionsApiV1ReplaySessionsGetResponse, ListReplaySessionsApiV1ReplaySessionsGetResponses, ListSagasApiV1SagasGetData, ListSagasApiV1SagasGetError, ListSagasApiV1SagasGetErrors, ListSagasApiV1SagasGetResponse, ListSagasApiV1SagasGetResponses, ListSavedScriptsApiV1ScriptsGetData, ListSavedScriptsApiV1ScriptsGetResponse, ListSavedScriptsApiV1ScriptsGetResponses, ListUsersApiV1AdminUsersGetData, ListUsersApiV1AdminUsersGetError, ListUsersApiV1AdminUsersGetErrors, ListUsersApiV1AdminUsersGetResponse, ListUsersApiV1AdminUsersGetResponses, LivenessApiV1HealthLiveGetData, LivenessApiV1HealthLiveGetResponse, LivenessApiV1HealthLiveGetResponses, LivenessResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginResponse, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponse, LogoutApiV1AuthLogoutPostResponses, ManualRetryRequest, MarkAllReadApiV1NotificationsMarkAllReadPostData, MarkAllReadApiV1NotificationsMarkAllReadPostResponse, MarkAllReadApiV1NotificationsMarkAllReadPostResponses, MarkNotificationReadApiV1NotificationsNotificationIdReadPutData, MarkNotificationReadApiV1NotificationsNotificationIdReadPutError, MarkNotificationReadApiV1NotificationsNotificationIdReadPutErrors, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponse, MarkNotificationReadApiV1NotificationsNotificationIdReadPutResponses, MessageResponse, MonitoringSettingsSchema, NotificationChannel, NotificationListResponse, NotificationResponse, NotificationSettings, NotificationSeverity, NotificationStatus, NotificationStreamApiV1EventsNotificationsStreamGetData, NotificationStreamApiV1EventsNotificationsStreamGetResponses, NotificationSubscription, PasswordResetRequest, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostData, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostError, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostErrors, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponse, PauseReplaySessionApiV1ReplaySessionsSessionIdPausePostResponses, PublishCustomEventApiV1EventsPublishPostData, PublishCustomEventApiV1EventsPublishPostError, PublishCustomEventApiV1EventsPublishPostErrors, PublishCustomEventApiV1EventsPublishPostResponse, PublishCustomEventApiV1EventsPublishPostResponses, PublishEventRequest, PublishEventResponse, QueryEventsApiV1EventsQueryPostData, QueryEventsApiV1EventsQueryPostError, QueryEventsApiV1EventsQueryPostErrors, QueryEventsApiV1EventsQueryPostResponse, QueryEventsApiV1EventsQueryPostResponses, RateLimitAlgorithm, RateLimitRule, RateLimitRuleResponse, RateLimitSummary, RateLimitUpdateResponse, ReadinessApiV1HealthReadyGetData, ReadinessApiV1HealthReadyGetResponse, ReadinessApiV1HealthReadyGetResponses, ReadinessResponse, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostData, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostError, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostErrors, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostResponse, ReceiveGrafanaAlertsApiV1AlertsGrafanaPostResponses, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostError, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponse, RegisterApiV1AuthRegisterPostResponses, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostData, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostError, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostErrors, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostResponse, ReplayAggregateEventsApiV1EventsReplayAggregateIdPostResponses, ReplayAggregateResponse, ReplayConfigSchema, ReplayEventsApiV1AdminEventsReplayPostData, ReplayEventsApiV1AdminEventsReplayPostError, ReplayEventsApiV1AdminEventsReplayPostErrors, ReplayEventsApiV1AdminEventsReplayPostResponse, ReplayEventsApiV1AdminEventsReplayPostResponses, ReplayFilterSchema, ReplayRequest, ReplayResponse, ReplaySession, ReplayStatus, ReplayTarget, ReplayType, ResetSystemSettingsApiV1AdminSettingsResetPostData, ResetSystemSettingsApiV1AdminSettingsResetPostResponse, ResetSystemSettingsApiV1AdminSettingsResetPostResponses, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostData, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostError, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostErrors, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponse, ResetUserPasswordApiV1AdminUsersUserIdResetPasswordPostResponses, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostData, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostError, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostErrors, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostResponse, ResetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPostResponses, ResourceLimits, ResourceUsage, RestoreSettingsApiV1UserSettingsRestorePostData, RestoreSettingsApiV1UserSettingsRestorePostError, RestoreSettingsApiV1UserSettingsRestorePostErrors, RestoreSettingsApiV1UserSettingsRestorePostResponse, RestoreSettingsApiV1UserSettingsRestorePostResponses, RestoreSettingsRequest, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostData, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostError, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostErrors, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponse, ResumeReplaySessionApiV1ReplaySessionsSessionIdResumePostResponses, RetryDlqMessagesApiV1DlqRetryPostData, RetryDlqMessagesApiV1DlqRetryPostError, RetryDlqMessagesApiV1DlqRetryPostErrors, RetryDlqMessagesApiV1DlqRetryPostResponse, RetryDlqMessagesApiV1DlqRetryPostResponses, RetryExecutionApiV1ExecutionIdRetryPostData, RetryExecutionApiV1ExecutionIdRetryPostError, RetryExecutionApiV1ExecutionIdRetryPostErrors, RetryExecutionApiV1ExecutionIdRetryPostResponse, RetryExecutionApiV1ExecutionIdRetryPostResponses, RetryExecutionRequest, RetryPolicyRequest, RetryStrategy, SagaCancellationResponse, SagaListResponse, SagaState, SagaStatusResponse, SavedScriptCreateRequest, SavedScriptResponse, SecuritySettingsSchema, SessionSummary, SetRetryPolicyApiV1DlqRetryPolicyPostData, SetRetryPolicyApiV1DlqRetryPolicyPostError, SetRetryPolicyApiV1DlqRetryPolicyPostErrors, SetRetryPolicyApiV1DlqRetryPolicyPostResponse, SetRetryPolicyApiV1DlqRetryPolicyPostResponses, SettingsHistoryEntry, SettingsHistoryResponse, ShutdownStatusResponse, SortOrder, SseHealthApiV1EventsHealthGetData, SseHealthApiV1EventsHealthGetResponse, SseHealthApiV1EventsHealthGetResponses, SseHealthResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostData, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostError, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostErrors, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponse, StartReplaySessionApiV1ReplaySessionsSessionIdStartPostResponses, SubscriptionsResponse, SubscriptionUpdate, SystemSettings, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetData, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetResponse, TestGrafanaAlertEndpointApiV1AlertsGrafanaTestGetResponses, Theme, ThemeUpdateRequest, TokenValidationResponse, UnreadCountResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutData, UpdateCustomSettingApiV1UserSettingsCustomKeyPutError, UpdateCustomSettingApiV1UserSettingsCustomKeyPutErrors, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponse, UpdateCustomSettingApiV1UserSettingsCustomKeyPutResponses, UpdateEditorSettingsApiV1UserSettingsEditorPutData, UpdateEditorSettingsApiV1UserSettingsEditorPutError, UpdateEditorSettingsApiV1UserSettingsEditorPutErrors, UpdateEditorSettingsApiV1UserSettingsEditorPutResponse, UpdateEditorSettingsApiV1UserSettingsEditorPutResponses, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutData, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutError, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutErrors, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponse, UpdateNotificationSettingsApiV1UserSettingsNotificationsPutResponses, UpdateSavedScriptApiV1ScriptsScriptIdPutData, UpdateSavedScriptApiV1ScriptsScriptIdPutError, UpdateSavedScriptApiV1ScriptsScriptIdPutErrors, UpdateSavedScriptApiV1ScriptsScriptIdPutResponse, UpdateSavedScriptApiV1ScriptsScriptIdPutResponses, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutData, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutError, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutErrors, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponse, UpdateSubscriptionApiV1NotificationsSubscriptionsChannelPutResponses, UpdateSystemSettingsApiV1AdminSettingsPutData, UpdateSystemSettingsApiV1AdminSettingsPutError, UpdateSystemSettingsApiV1AdminSettingsPutErrors, UpdateSystemSettingsApiV1AdminSettingsPutResponse, UpdateSystemSettingsApiV1AdminSettingsPutResponses, UpdateThemeApiV1UserSettingsThemePutData, UpdateThemeApiV1UserSettingsThemePutError, UpdateThemeApiV1UserSettingsThemePutErrors, UpdateThemeApiV1UserSettingsThemePutResponse, UpdateThemeApiV1UserSettingsThemePutResponses, UpdateUserApiV1AdminUsersUserIdPutData, UpdateUserApiV1AdminUsersUserIdPutError, UpdateUserApiV1AdminUsersUserIdPutErrors, UpdateUserApiV1AdminUsersUserIdPutResponse, UpdateUserApiV1AdminUsersUserIdPutResponses, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutData, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutError, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutErrors, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponse, UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponses, UpdateUserSettingsApiV1UserSettingsPutData, UpdateUserSettingsApiV1UserSettingsPutError, UpdateUserSettingsApiV1UserSettingsPutErrors, UpdateUserSettingsApiV1UserSettingsPutResponse, UpdateUserSettingsApiV1UserSettingsPutResponses, UserCreate, UserListResponse, UserRateLimit, UserRateLimitConfigResponse, UserRateLimitsResponse, UserResponse, UserRole, UserSettings, UserSettingsUpdate, UserUpdate, ValidationError, VerifyTokenApiV1AuthVerifyTokenGetData, VerifyTokenApiV1AuthVerifyTokenGetResponse, VerifyTokenApiV1AuthVerifyTokenGetResponses } from './types.gen'; diff --git a/frontend/src/lib/api/sdk.gen.ts b/frontend/src/lib/api/sdk.gen.ts index 73b2c98..503b996 100644 --- a/frontend/src/lib/api/sdk.gen.ts +++ b/frontend/src/lib/api/sdk.gen.ts @@ -404,6 +404,18 @@ export const browseEventsApiV1AdminEventsBrowsePost = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/admin/events/stats', ...options }); +/** + * Export Events Csv + */ +export const exportEventsCsvApiV1AdminEventsExportCsvGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/admin/events/export/csv', ...options }); + +/** + * Export Events Json + * + * Export events as JSON with comprehensive filtering. + */ +export const exportEventsJsonApiV1AdminEventsExportJsonGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/admin/events/export/json', ...options }); + /** * Delete Event */ @@ -431,18 +443,6 @@ export const replayEventsApiV1AdminEventsReplayPost = (options: Options) => (options.client ?? client).get({ url: '/api/v1/admin/events/replay/{session_id}/status', ...options }); -/** - * Export Events Csv - */ -export const exportEventsCsvApiV1AdminEventsExportCsvGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/admin/events/export/csv', ...options }); - -/** - * Export Events Json - * - * Export events as JSON with comprehensive filtering. - */ -export const exportEventsJsonApiV1AdminEventsExportJsonGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/admin/events/export/json', ...options }); - /** * Get System Settings */ diff --git a/frontend/src/lib/api/types.gen.ts b/frontend/src/lib/api/types.gen.ts index 849b404..7c0717c 100644 --- a/frontend/src/lib/api/types.gen.ts +++ b/frontend/src/lib/api/types.gen.ts @@ -429,6 +429,24 @@ export type DeleteResponse = { execution_id: string; }; +/** + * DeleteUserResponse + * + * Response model for user deletion. + */ +export type DeleteUserResponse = { + /** + * Message + */ + message: string; + /** + * Deleted Counts + */ + deleted_counts: { + [key: string]: number; + }; +}; + /** * DerivedCounts */ @@ -1275,6 +1293,48 @@ export type HttpValidationError = { detail?: Array; }; +/** + * LanguageInfo + * + * Language runtime information. + */ +export type LanguageInfo = { + /** + * Versions + */ + versions: Array; + /** + * File Ext + */ + file_ext: string; +}; + +/** + * LivenessResponse + * + * Response model for liveness probe. + */ +export type LivenessResponse = { + /** + * Status + * + * Health status + */ + status: string; + /** + * Uptime Seconds + * + * Server uptime in seconds + */ + uptime_seconds: number; + /** + * Timestamp + * + * ISO timestamp of health check + */ + timestamp: string; +}; + /** * LoginResponse * @@ -1649,6 +1709,46 @@ export type RateLimitRule = { compiled_pattern?: string | null; }; +/** + * RateLimitRuleResponse + * + * Response model for rate limit rule. + */ +export type RateLimitRuleResponse = { + /** + * Endpoint Pattern + */ + endpoint_pattern: string; + /** + * Group + */ + group: string; + /** + * Requests + */ + requests: number; + /** + * Window Seconds + */ + window_seconds: number; + /** + * Algorithm + */ + algorithm: string; + /** + * Burst Multiplier + */ + burst_multiplier?: number; + /** + * Priority + */ + priority?: number; + /** + * Enabled + */ + enabled?: boolean; +}; + /** * RateLimitSummary */ @@ -1667,6 +1767,43 @@ export type RateLimitSummary = { has_custom_limits?: boolean | null; }; +/** + * RateLimitUpdateResponse + * + * Response model for rate limit update. + */ +export type RateLimitUpdateResponse = { + /** + * User Id + */ + user_id: string; + /** + * Updated + */ + updated: boolean; + config: UserRateLimitConfigResponse; +}; + +/** + * ReadinessResponse + * + * Response model for readiness probe. + */ +export type ReadinessResponse = { + /** + * Status + * + * Readiness status + */ + status: string; + /** + * Uptime Seconds + * + * Server uptime in seconds + */ + uptime_seconds: number; +}; + /** * ReplayAggregateResponse * @@ -1968,7 +2105,7 @@ export type ResourceLimits = { * Supported Runtimes */ supported_runtimes: { - [key: string]: Array; + [key: string]: LanguageInfo; }; }; @@ -2119,13 +2256,9 @@ export type SseHealthResponse = { */ max_connections_per_user: number; /** - * Shutdown - * * Shutdown status information */ - shutdown: { - [key: string]: unknown; - }; + shutdown: ShutdownStatusResponse; /** * Timestamp * @@ -2429,6 +2562,50 @@ export type SettingsHistoryResponse = { total: number; }; +/** + * ShutdownStatusResponse + * + * Response model for shutdown status. + */ +export type ShutdownStatusResponse = { + /** + * Phase + * + * Current shutdown phase + */ + phase: string; + /** + * Initiated + * + * Whether shutdown has been initiated + */ + initiated: boolean; + /** + * Complete + * + * Whether shutdown is complete + */ + complete: boolean; + /** + * Active Connections + * + * Number of active connections + */ + active_connections: number; + /** + * Draining Connections + * + * Number of connections being drained + */ + draining_connections: number; + /** + * Duration + * + * Duration of shutdown in seconds + */ + duration?: number | null; +}; + /** * SortOrder * @@ -2646,6 +2823,63 @@ export type UserRateLimit = { notes?: string | null; }; +/** + * UserRateLimitConfigResponse + * + * Response model for user rate limit config. + */ +export type UserRateLimitConfigResponse = { + /** + * User Id + */ + user_id: string; + /** + * Bypass Rate Limit + */ + bypass_rate_limit: boolean; + /** + * Global Multiplier + */ + global_multiplier: number; + /** + * Rules + */ + rules: Array; + /** + * Created At + */ + created_at?: string | null; + /** + * Updated At + */ + updated_at?: string | null; + /** + * Notes + */ + notes?: string | null; +}; + +/** + * UserRateLimitsResponse + * + * Response model for user rate limits with usage stats. + */ +export type UserRateLimitsResponse = { + /** + * User Id + */ + user_id: string; + rate_limit_config?: UserRateLimitConfigResponse | null; + /** + * Current Usage + */ + current_usage: { + [key: string]: { + [key: string]: unknown; + }; + }; +}; + /** * UserResponse * @@ -3576,13 +3810,9 @@ export type LivenessApiV1HealthLiveGetData = { export type LivenessApiV1HealthLiveGetResponses = { /** - * Response Liveness Api V1 Health Live Get - * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: LivenessResponse; }; export type LivenessApiV1HealthLiveGetResponse = LivenessApiV1HealthLiveGetResponses[keyof LivenessApiV1HealthLiveGetResponses]; @@ -3596,13 +3826,9 @@ export type ReadinessApiV1HealthReadyGetData = { export type ReadinessApiV1HealthReadyGetResponses = { /** - * Response Readiness Api V1 Health Ready Get - * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: ReadinessResponse; }; export type ReadinessApiV1HealthReadyGetResponse = ReadinessApiV1HealthReadyGetResponses[keyof ReadinessApiV1HealthReadyGetResponses]; @@ -4316,121 +4542,6 @@ export type GetEventStatsApiV1AdminEventsStatsGetResponses = { export type GetEventStatsApiV1AdminEventsStatsGetResponse = GetEventStatsApiV1AdminEventsStatsGetResponses[keyof GetEventStatsApiV1AdminEventsStatsGetResponses]; -export type DeleteEventApiV1AdminEventsEventIdDeleteData = { - body?: never; - path: { - /** - * Event Id - */ - event_id: string; - }; - query?: never; - url: '/api/v1/admin/events/{event_id}'; -}; - -export type DeleteEventApiV1AdminEventsEventIdDeleteErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type DeleteEventApiV1AdminEventsEventIdDeleteError = DeleteEventApiV1AdminEventsEventIdDeleteErrors[keyof DeleteEventApiV1AdminEventsEventIdDeleteErrors]; - -export type DeleteEventApiV1AdminEventsEventIdDeleteResponses = { - /** - * Successful Response - */ - 200: EventDeleteResponse; -}; - -export type DeleteEventApiV1AdminEventsEventIdDeleteResponse = DeleteEventApiV1AdminEventsEventIdDeleteResponses[keyof DeleteEventApiV1AdminEventsEventIdDeleteResponses]; - -export type GetEventDetailApiV1AdminEventsEventIdGetData = { - body?: never; - path: { - /** - * Event Id - */ - event_id: string; - }; - query?: never; - url: '/api/v1/admin/events/{event_id}'; -}; - -export type GetEventDetailApiV1AdminEventsEventIdGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetEventDetailApiV1AdminEventsEventIdGetError = GetEventDetailApiV1AdminEventsEventIdGetErrors[keyof GetEventDetailApiV1AdminEventsEventIdGetErrors]; - -export type GetEventDetailApiV1AdminEventsEventIdGetResponses = { - /** - * Successful Response - */ - 200: EventDetailResponse; -}; - -export type GetEventDetailApiV1AdminEventsEventIdGetResponse = GetEventDetailApiV1AdminEventsEventIdGetResponses[keyof GetEventDetailApiV1AdminEventsEventIdGetResponses]; - -export type ReplayEventsApiV1AdminEventsReplayPostData = { - body: EventReplayRequest; - path?: never; - query?: never; - url: '/api/v1/admin/events/replay'; -}; - -export type ReplayEventsApiV1AdminEventsReplayPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ReplayEventsApiV1AdminEventsReplayPostError = ReplayEventsApiV1AdminEventsReplayPostErrors[keyof ReplayEventsApiV1AdminEventsReplayPostErrors]; - -export type ReplayEventsApiV1AdminEventsReplayPostResponses = { - /** - * Successful Response - */ - 200: EventReplayResponse; -}; - -export type ReplayEventsApiV1AdminEventsReplayPostResponse = ReplayEventsApiV1AdminEventsReplayPostResponses[keyof ReplayEventsApiV1AdminEventsReplayPostResponses]; - -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData = { - body?: never; - path: { - /** - * Session Id - */ - session_id: string; - }; - query?: never; - url: '/api/v1/admin/events/replay/{session_id}/status'; -}; - -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors]; - -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses = { - /** - * Successful Response - */ - 200: EventReplayStatusResponse; -}; - -export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses]; - export type ExportEventsCsvApiV1AdminEventsExportCsvGetData = { body?: never; path?: never; @@ -4547,6 +4658,121 @@ export type ExportEventsJsonApiV1AdminEventsExportJsonGetResponses = { 200: unknown; }; +export type DeleteEventApiV1AdminEventsEventIdDeleteData = { + body?: never; + path: { + /** + * Event Id + */ + event_id: string; + }; + query?: never; + url: '/api/v1/admin/events/{event_id}'; +}; + +export type DeleteEventApiV1AdminEventsEventIdDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteEventApiV1AdminEventsEventIdDeleteError = DeleteEventApiV1AdminEventsEventIdDeleteErrors[keyof DeleteEventApiV1AdminEventsEventIdDeleteErrors]; + +export type DeleteEventApiV1AdminEventsEventIdDeleteResponses = { + /** + * Successful Response + */ + 200: EventDeleteResponse; +}; + +export type DeleteEventApiV1AdminEventsEventIdDeleteResponse = DeleteEventApiV1AdminEventsEventIdDeleteResponses[keyof DeleteEventApiV1AdminEventsEventIdDeleteResponses]; + +export type GetEventDetailApiV1AdminEventsEventIdGetData = { + body?: never; + path: { + /** + * Event Id + */ + event_id: string; + }; + query?: never; + url: '/api/v1/admin/events/{event_id}'; +}; + +export type GetEventDetailApiV1AdminEventsEventIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetEventDetailApiV1AdminEventsEventIdGetError = GetEventDetailApiV1AdminEventsEventIdGetErrors[keyof GetEventDetailApiV1AdminEventsEventIdGetErrors]; + +export type GetEventDetailApiV1AdminEventsEventIdGetResponses = { + /** + * Successful Response + */ + 200: EventDetailResponse; +}; + +export type GetEventDetailApiV1AdminEventsEventIdGetResponse = GetEventDetailApiV1AdminEventsEventIdGetResponses[keyof GetEventDetailApiV1AdminEventsEventIdGetResponses]; + +export type ReplayEventsApiV1AdminEventsReplayPostData = { + body: EventReplayRequest; + path?: never; + query?: never; + url: '/api/v1/admin/events/replay'; +}; + +export type ReplayEventsApiV1AdminEventsReplayPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReplayEventsApiV1AdminEventsReplayPostError = ReplayEventsApiV1AdminEventsReplayPostErrors[keyof ReplayEventsApiV1AdminEventsReplayPostErrors]; + +export type ReplayEventsApiV1AdminEventsReplayPostResponses = { + /** + * Successful Response + */ + 200: EventReplayResponse; +}; + +export type ReplayEventsApiV1AdminEventsReplayPostResponse = ReplayEventsApiV1AdminEventsReplayPostResponses[keyof ReplayEventsApiV1AdminEventsReplayPostResponses]; + +export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: string; + }; + query?: never; + url: '/api/v1/admin/events/replay/{session_id}/status'; +}; + +export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetError = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetErrors]; + +export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses = { + /** + * Successful Response + */ + 200: EventReplayStatusResponse; +}; + +export type GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponse = GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses[keyof GetReplayStatusApiV1AdminEventsReplaySessionIdStatusGetResponses]; + export type GetSystemSettingsApiV1AdminSettingsGetData = { body?: never; path?: never; @@ -4701,13 +4927,9 @@ export type DeleteUserApiV1AdminUsersUserIdDeleteError = DeleteUserApiV1AdminUse export type DeleteUserApiV1AdminUsersUserIdDeleteResponses = { /** - * Response Delete User Api V1 Admin Users User Id Delete - * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: DeleteUserResponse; }; export type DeleteUserApiV1AdminUsersUserIdDeleteResponse = DeleteUserApiV1AdminUsersUserIdDeleteResponses[keyof DeleteUserApiV1AdminUsersUserIdDeleteResponses]; @@ -4855,13 +5077,9 @@ export type GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetError = GetUserRa export type GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponses = { /** - * Response Get User Rate Limits Api V1 Admin Users User Id Rate Limits Get - * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: UserRateLimitsResponse; }; export type GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponse = GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponses[keyof GetUserRateLimitsApiV1AdminUsersUserIdRateLimitsGetResponses]; @@ -4889,13 +5107,9 @@ export type UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutError = Update export type UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponses = { /** - * Response Update User Rate Limits Api V1 Admin Users User Id Rate Limits Put - * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: RateLimitUpdateResponse; }; export type UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponse = UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponses[keyof UpdateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPutResponses]; diff --git a/frontend/src/routes/Editor.svelte b/frontend/src/routes/Editor.svelte index c654df9..b817580 100644 --- a/frontend/src/routes/Editor.svelte +++ b/frontend/src/routes/Editor.svelte @@ -14,6 +14,7 @@ deleteSavedScriptApiV1ScriptsScriptIdDelete, type K8sResourceLimits, type ExecutionResult, + type LanguageInfo, } from "../lib/api"; // Local interface for saved script data used in the editor @@ -33,6 +34,11 @@ import {EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers} from "@codemirror/view"; import {defaultKeymap, history, historyKeymap, indentWithTab} from "@codemirror/commands"; import {python} from "@codemirror/lang-python"; + import {javascript} from "@codemirror/lang-javascript"; + import {go} from "@codemirror/lang-go"; + import {StreamLanguage} from "@codemirror/language"; + import {ruby} from "@codemirror/legacy-modes/mode/ruby"; + import {shell} from "@codemirror/legacy-modes/mode/shell"; import {oneDark} from "@codemirror/theme-one-dark"; import {githubLight} from "@uiw/codemirror-theme-github"; import {bracketMatching} from "@codemirror/language"; @@ -49,7 +55,20 @@ let tabSizeCompartment = new Compartment(); let lineNumbersCompartment = new Compartment(); let lineWrappingCompartment = new Compartment(); - + let languageCompartment = new Compartment(); + + // Language extension mapping + function getLanguageExtension(lang: string) { + switch (lang) { + case 'python': return python(); + case 'node': return javascript(); + case 'go': return go(); + case 'ruby': return StreamLanguage.define(ruby); + case 'bash': return StreamLanguage.define(shell); + default: return python(); + } + } + // Default editor settings let editorSettings = { theme: 'auto', // Default to following app theme @@ -135,7 +154,11 @@ // Updated state for language and version selection let selectedLang = writable("python"); let selectedVersion = writable("3.11"); - let supportedRuntimes = $state>({}); + let supportedRuntimes = $state>({}); + let acceptedFileExts = $derived( + Object.values(supportedRuntimes).map(i => `.${i.file_ext}`).join(',') || '.txt' + ); + let runtimesAvailable = $derived(Object.keys(supportedRuntimes).length > 0); let showLangOptions = $state(false); let hoveredLang = $state(null); @@ -159,6 +182,7 @@ let unsubscribeSettings; let unsubscribeScriptId; let unsubscribeScriptName; + let unsubscribeLang; const resourceIcon = ``; const chevronDownIcon = ``; @@ -237,16 +261,18 @@ const { data: limitsData, error: limitsError } = await getK8sResourceLimitsApiV1K8sLimitsGet({}); if (limitsError) { - supportedRuntimes = {"python": ["3.9", "3.10", "3.11"]}; + addToast("Failed to load runtime configuration. Execution disabled.", "error"); + supportedRuntimes = {}; } else { k8sLimits = limitsData; - supportedRuntimes = k8sLimits?.supported_runtimes || {"python": ["3.9", "3.10", "3.11"]}; + supportedRuntimes = k8sLimits?.supported_runtimes || {}; const currentLang = get(selectedLang); const currentVersion = get(selectedVersion); - if (!supportedRuntimes[currentLang] || !supportedRuntimes[currentLang].includes(currentVersion)) { + const langInfo = supportedRuntimes[currentLang]; + if (!langInfo || !langInfo.versions.includes(currentVersion)) { const firstLang = Object.keys(supportedRuntimes)[0]; if (firstLang) { - const firstVersion = supportedRuntimes[firstLang][0]; + const firstVersion = supportedRuntimes[firstLang].versions[0]; selectedLang.set(firstLang); if (firstVersion) selectedVersion.set(firstVersion); } @@ -269,6 +295,15 @@ } }); + unsubscribeLang = selectedLang.subscribe(lang => { + // Update syntax highlighting when language changes + if (editorView) { + editorView.dispatch({ + effects: languageCompartment.reconfigure(getLanguageExtension(lang)) + }); + } + }); + if (authenticated) { await loadSavedScripts(); } @@ -284,6 +319,7 @@ if (unsubscribeSettings) unsubscribeSettings(); if (unsubscribeScriptId) unsubscribeScriptId(); if (unsubscribeScriptName) unsubscribeScriptName(); + if (unsubscribeLang) unsubscribeLang(); }); function getStaticExtensions() { @@ -302,7 +338,7 @@ ...completionKeymap, indentWithTab ]), - python(), + languageCompartment.of(getLanguageExtension(get(selectedLang))), lineWrappingCompartment.of(editorSettings.word_wrap ? EditorView.lineWrapping : []), fontSizeCompartment.of(EditorView.theme({ ".cm-content": { @@ -634,9 +670,12 @@ const scriptValue = get(script); const blob = new Blob([scriptValue], {type: "text/plain;charset=utf-8"}); const url = URL.createObjectURL(blob); - let filename = get(scriptName).trim() || "script.py"; - if (!filename.toLowerCase().endsWith(".py")) { - filename += ".py"; + const lang = get(selectedLang); + const langInfo = supportedRuntimes[lang]; + const ext = langInfo?.file_ext || "txt"; + let filename = get(scriptName).trim() || `script.${ext}`; + if (!filename.toLowerCase().endsWith(`.${ext}`)) { + filename = filename.replace(/\.[^.]+$/, "") + `.${ext}`; } const a = document.createElement("a"); a.href = url; @@ -651,10 +690,23 @@ const target = event.target as HTMLInputElement; const file = target.files?.[0]; if (!file) return; - if (!file.name.toLowerCase().endsWith(".py")) { - addToast("Only .py files are allowed.", "error"); + + // Build extension to language map from supportedRuntimes + const extToLang: Record = {}; + for (const [lang, info] of Object.entries(supportedRuntimes)) { + extToLang[info.file_ext] = lang; + } + + // Get file extension + const fileExt = file.name.split('.').pop()?.toLowerCase() || ""; + const detectedLang = extToLang[fileExt]; + + if (!detectedLang) { + const supportedExts = Object.values(supportedRuntimes).map(i => `.${i.file_ext}`).join(", "); + addToast(`Unsupported file type. Allowed: ${supportedExts}`, "error"); return; } + const reader = new FileReader(); reader.onload = (e: ProgressEvent) => { const text = e.target?.result as string; @@ -662,11 +714,19 @@ newScript(); script.set(text); scriptName.set(file.name); + + // Auto-select detected language and first available version + selectedLang.set(detectedLang); + const langInfo = supportedRuntimes[detectedLang]; + if (langInfo?.versions.length > 0) { + selectedVersion.set(langInfo.versions[0]); + } + editorView.dispatch({ changes: {from: 0, to: editorView.state.doc.length, insert: text}, selection: {anchor: 0} }); - addToast(`Loaded script from ${file.name}`, "info"); + addToast(`Loaded ${detectedLang} script from ${file.name}`, "info"); } }; reader.onerror = () => { @@ -757,7 +817,7 @@ } - +
@@ -983,29 +1043,32 @@
- {#if showLangOptions} + {#if showLangOptions && runtimesAvailable}
    hoveredLang = null}> - {#each Object.entries(supportedRuntimes) as [lang, versions] (lang)} + {#each Object.entries(supportedRuntimes) as [lang, langInfo] (lang)}
  • hoveredLang = lang}>
    {lang}
    - {#if hoveredLang === lang && versions.length > 0} + {#if hoveredLang === lang && langInfo.versions.length > 0}
      - {#each versions as version (version)} + {#each langInfo.versions as version (version)}
    • {/each} - {#if Object.keys(supportedRuntimes).length === 0} -
    • No runtimes available
    • - {/if}
    {/if}
From 171e16155635e57b77b7625eb52ae03c8b48d099 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 18:49:47 +0100 Subject: [PATCH 03/17] small fixes --- backend/app/schemas_pydantic/execution.py | 9 ++++----- .../src/lib/__tests__/user-settings.test.ts | 14 +++++++------- frontend/src/lib/user-settings.ts | 17 ++++++----------- frontend/src/stores/userSettings.ts | 4 ---- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/backend/app/schemas_pydantic/execution.py b/backend/app/schemas_pydantic/execution.py index 88a6d09..c3d45cd 100644 --- a/backend/app/schemas_pydantic/execution.py +++ b/backend/app/schemas_pydantic/execution.py @@ -73,13 +73,12 @@ class ExecutionRequest(BaseModel): @model_validator(mode="after") def validate_runtime_supported(self) -> "ExecutionRequest": # noqa: D401 - settings = get_settings() - runtimes = settings.SUPPORTED_RUNTIMES or {} - if self.lang not in runtimes: + runtimes = get_settings().SUPPORTED_RUNTIMES + if not (lang_info := runtimes.get(self.lang)): raise ValueError(f"Language '{self.lang}' not supported. Supported: {list(runtimes.keys())}") - lang_info = runtimes[self.lang] if self.lang_version not in lang_info.versions: - raise ValueError(f"Version '{self.lang_version}' not supported for {self.lang}. Supported: {lang_info.versions}") + raise ValueError(f"Version '{self.lang_version}' not supported for {self.lang}. " + f"Supported: {lang_info.versions}") return self diff --git a/frontend/src/lib/__tests__/user-settings.test.ts b/frontend/src/lib/__tests__/user-settings.test.ts index 1745286..6a8eec6 100644 --- a/frontend/src/lib/__tests__/user-settings.test.ts +++ b/frontend/src/lib/__tests__/user-settings.test.ts @@ -9,11 +9,9 @@ vi.mock('../api', () => ({ })); const mockSetUserSettings = vi.fn(); -const mockUpdateSettings = vi.fn(); vi.mock('../../stores/userSettings', () => ({ setUserSettings: (settings: unknown) => mockSetUserSettings(settings), - updateSettings: (partial: unknown) => mockUpdateSettings(partial), })); const mockSetThemeLocal = vi.fn(); @@ -38,7 +36,6 @@ describe('user-settings', () => { mockGetUserSettings.mockReset(); mockUpdateUserSettings.mockReset(); mockSetUserSettings.mockReset(); - mockUpdateSettings.mockReset(); mockSetThemeLocal.mockReset(); mockIsAuthenticated = true; @@ -170,16 +167,18 @@ describe('user-settings', () => { }); it('updates store on success', async () => { - mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + const responseData = { user_id: '123', theme: 'system' }; + mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null }); const { saveUserSettings } = await import('../user-settings'); await saveUserSettings({ theme: 'system' }); - expect(mockUpdateSettings).toHaveBeenCalledWith({ theme: 'system' }); + expect(mockSetUserSettings).toHaveBeenCalledWith(responseData); }); it('applies theme locally when theme is saved', async () => { - mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + const responseData = { user_id: '123', theme: 'dark' }; + mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null }); const { saveUserSettings } = await import('../user-settings'); await saveUserSettings({ theme: 'dark' }); @@ -188,7 +187,8 @@ describe('user-settings', () => { }); it('does not apply theme when only editor settings saved', async () => { - mockUpdateUserSettings.mockResolvedValue({ data: {}, error: null }); + const responseData = { user_id: '123', editor: { font_size: 16 } }; + mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null }); const { saveUserSettings } = await import('../user-settings'); await saveUserSettings({ editor: { font_size: 16 } }); diff --git a/frontend/src/lib/user-settings.ts b/frontend/src/lib/user-settings.ts index a7756ce..10fd489 100644 --- a/frontend/src/lib/user-settings.ts +++ b/frontend/src/lib/user-settings.ts @@ -1,13 +1,14 @@ import { get } from 'svelte/store'; import { isAuthenticated } from '../stores/auth'; import { setThemeLocal } from '../stores/theme'; -import { setUserSettings, updateSettings } from '../stores/userSettings'; +import { setUserSettings } from '../stores/userSettings'; import { getUserSettingsApiV1UserSettingsGet, updateUserSettingsApiV1UserSettingsPut, type UserSettings, type UserSettingsUpdate, } from './api'; +import { unwrap } from './api-interceptors'; export async function loadUserSettings(): Promise { try { @@ -34,17 +35,11 @@ export async function saveUserSettings(partial: UserSettingsUpdate): Promise); + const data = unwrap(await updateUserSettingsApiV1UserSettingsPut({ body: partial })); + setUserSettings(data); - if (partial.theme) { - setThemeLocal(partial.theme); + if (data.theme) { + setThemeLocal(data.theme); } return true; diff --git a/frontend/src/stores/userSettings.ts b/frontend/src/stores/userSettings.ts index f4edc49..668bc85 100644 --- a/frontend/src/stores/userSettings.ts +++ b/frontend/src/stores/userSettings.ts @@ -21,10 +21,6 @@ export function setUserSettings(settings: UserSettings | null): void { userSettings.set(settings); } -export function updateSettings(partial: Partial): void { - userSettings.update(current => current ? { ...current, ...partial } : null); -} - export function clearUserSettings(): void { userSettings.set(null); } From d80be860cbed59e58342456d9bad8d30cf346ba3 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 19:11:40 +0100 Subject: [PATCH 04/17] failed tests fix --- backend/app/api/routes/execution.py | 3 ++- backend/tests/integration/test_user_settings_routes.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/execution.py b/backend/app/api/routes/execution.py index ef3e9a4..6efc1f8 100644 --- a/backend/app/api/routes/execution.py +++ b/backend/app/api/routes/execution.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from datetime import datetime, timezone from typing import Annotated from uuid import uuid4 @@ -336,7 +337,7 @@ async def get_k8s_resource_limits( ) -> ResourceLimits: try: limits = await execution_service.get_k8s_resource_limits() - return ResourceLimits(**vars(limits)) + return ResourceLimits(**asdict(limits)) except Exception as e: raise HTTPException(status_code=500, detail="Failed to retrieve resource limits") from e diff --git a/backend/tests/integration/test_user_settings_routes.py b/backend/tests/integration/test_user_settings_routes.py index 20b89dc..c637835 100644 --- a/backend/tests/integration/test_user_settings_routes.py +++ b/backend/tests/integration/test_user_settings_routes.py @@ -129,7 +129,7 @@ async def test_get_user_settings(self, client: AsyncClient, test_user: Dict[str, assert settings.editor is not None assert isinstance(settings.editor.font_size, int) assert 8 <= settings.editor.font_size <= 32 - assert settings.editor.theme in ["one-dark", "monokai", "github", "dracula", "solarized", "vs", "vscode"] + assert settings.editor.theme in ["auto", "one-dark", "monokai", "github", "dracula", "solarized", "vs", "vscode"] assert isinstance(settings.editor.tab_size, int) assert settings.editor.tab_size in [2, 4, 8] assert isinstance(settings.editor.word_wrap, bool) From 05c79d9de0179940def3e65aa6f709ebbcd2bb9e Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 19:18:48 +0100 Subject: [PATCH 05/17] loading state fix --- frontend/src/components/NotificationCenter.svelte | 5 ++--- frontend/src/stores/notificationStore.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/NotificationCenter.svelte b/frontend/src/components/NotificationCenter.svelte index 2072b18..c864acb 100644 --- a/frontend/src/components/NotificationCenter.svelte +++ b/frontend/src/components/NotificationCenter.svelte @@ -4,11 +4,10 @@ import { isAuthenticated, username, userId } from '../stores/auth'; import { get } from 'svelte/store'; import { goto } from '@mateothegreat/svelte5-router'; - import { notificationStore, notifications, unreadCount } from '../stores/notificationStore'; + import { notificationStore, notifications, unreadCount, loading } from '../stores/notificationStore'; import type { NotificationResponse } from '../lib/api'; let showDropdown = $state(false); - let loading = $state(false); // EventSource and reconnect state - not displayed in template, no $state needed let eventSource: EventSource | null = null; let reconnectAttempts = 0; @@ -246,7 +245,7 @@
- {#if loading} + {#if $loading}
diff --git a/frontend/src/stores/notificationStore.ts b/frontend/src/stores/notificationStore.ts index 657ede6..ec5debd 100644 --- a/frontend/src/stores/notificationStore.ts +++ b/frontend/src/stores/notificationStore.ts @@ -99,3 +99,4 @@ function createNotificationStore() { export const notificationStore = createNotificationStore(); export const unreadCount = derived(notificationStore, s => s.notifications.filter(n => n.status !== 'read').length); export const notifications = derived(notificationStore, s => s.notifications); +export const loading = derived(notificationStore, s => s.loading); From 9f674c3ffabb245ab0760059c0356dfed51734da Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 19:23:45 +0100 Subject: [PATCH 06/17] mock added --- frontend/src/components/__tests__/NotificationCenter.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index e4e007f..5ad1e85 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -48,6 +48,7 @@ const mocks = vi.hoisted(() => { mockNotificationsState, mockNotifications: createDerivedStore(mockNotificationsState, s => s.notifications), mockUnreadCount: createDerivedStore(mockNotificationsState, s => s.notifications.filter(n => n.status !== 'read').length), + mockLoading: createDerivedStore(mockNotificationsState, s => s.loading), mockGoto: null as unknown as ReturnType, mockNotificationStore: null as unknown as { subscribe: typeof mockNotificationsState.subscribe; @@ -85,6 +86,7 @@ vi.mock('../../stores/notificationStore', () => ({ get notificationStore() { return mocks.mockNotificationStore; }, get notifications() { return mocks.mockNotifications; }, get unreadCount() { return mocks.mockUnreadCount; }, + get loading() { return mocks.mockLoading; }, })); // Mock EventSource with instance tracking From c7bce43bf243ce1035c2be0e89c8a6f6088d453f Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 19:47:40 +0100 Subject: [PATCH 07/17] double opt-in for notifications --- .../src/components/NotificationCenter.svelte | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/NotificationCenter.svelte b/frontend/src/components/NotificationCenter.svelte index c864acb..543516e 100644 --- a/frontend/src/components/NotificationCenter.svelte +++ b/frontend/src/components/NotificationCenter.svelte @@ -204,10 +204,18 @@ } // getNotificationIcon now based on tags above - - // Request notification permission - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission(); + + // Track permission state reactively + let notificationPermission = $state( + typeof window !== 'undefined' && 'Notification' in window + ? Notification.permission + : 'denied' + ); + + async function requestNotificationPermission(): Promise { + if (!('Notification' in window)) return; + const result = await Notification.requestPermission(); + notificationPermission = result; } @@ -242,6 +250,17 @@ {/if}
+ {#if notificationPermission === 'default'} + + {/if}
From 03ef734a5f8503a5037582e4299b552a12059ab5 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 20:14:39 +0100 Subject: [PATCH 08/17] unified theme setting (removed setThemeLocal) --- .../src/lib/__tests__/user-settings.test.ts | 14 ++++++------- frontend/src/lib/user-settings.ts | 6 +++--- frontend/src/routes/Settings.svelte | 9 ++++----- frontend/src/stores/__tests__/theme.test.ts | 20 ------------------- frontend/src/stores/theme.ts | 14 +++---------- 5 files changed, 17 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/__tests__/user-settings.test.ts b/frontend/src/lib/__tests__/user-settings.test.ts index 6a8eec6..31c2787 100644 --- a/frontend/src/lib/__tests__/user-settings.test.ts +++ b/frontend/src/lib/__tests__/user-settings.test.ts @@ -14,10 +14,10 @@ vi.mock('../../stores/userSettings', () => ({ setUserSettings: (settings: unknown) => mockSetUserSettings(settings), })); -const mockSetThemeLocal = vi.fn(); +const mockSetTheme = vi.fn(); vi.mock('../../stores/theme', () => ({ - setThemeLocal: (theme: string) => mockSetThemeLocal(theme), + setTheme: (theme: string) => mockSetTheme(theme), })); let mockIsAuthenticated = true; @@ -36,7 +36,7 @@ describe('user-settings', () => { mockGetUserSettings.mockReset(); mockUpdateUserSettings.mockReset(); mockSetUserSettings.mockReset(); - mockSetThemeLocal.mockReset(); + mockSetTheme.mockReset(); mockIsAuthenticated = true; @@ -83,7 +83,7 @@ describe('user-settings', () => { const { loadUserSettings } = await import('../user-settings'); await loadUserSettings(); - expect(mockSetThemeLocal).toHaveBeenCalledWith('dark'); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); }); it('returns undefined on API error', async () => { @@ -116,7 +116,7 @@ describe('user-settings', () => { const { loadUserSettings } = await import('../user-settings'); await loadUserSettings(); - expect(mockSetThemeLocal).not.toHaveBeenCalled(); + expect(mockSetTheme).not.toHaveBeenCalled(); }); }); @@ -183,7 +183,7 @@ describe('user-settings', () => { const { saveUserSettings } = await import('../user-settings'); await saveUserSettings({ theme: 'dark' }); - expect(mockSetThemeLocal).toHaveBeenCalledWith('dark'); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); }); it('does not apply theme when only editor settings saved', async () => { @@ -193,7 +193,7 @@ describe('user-settings', () => { const { saveUserSettings } = await import('../user-settings'); await saveUserSettings({ editor: { font_size: 16 } }); - expect(mockSetThemeLocal).not.toHaveBeenCalled(); + expect(mockSetTheme).not.toHaveBeenCalled(); }); it('returns true on success', async () => { diff --git a/frontend/src/lib/user-settings.ts b/frontend/src/lib/user-settings.ts index 10fd489..65de70a 100644 --- a/frontend/src/lib/user-settings.ts +++ b/frontend/src/lib/user-settings.ts @@ -1,6 +1,6 @@ import { get } from 'svelte/store'; import { isAuthenticated } from '../stores/auth'; -import { setThemeLocal } from '../stores/theme'; +import { setTheme } from '../stores/theme'; import { setUserSettings } from '../stores/userSettings'; import { getUserSettingsApiV1UserSettingsGet, @@ -22,7 +22,7 @@ export async function loadUserSettings(): Promise { setUserSettings(data); if (data.theme) { - setThemeLocal(data.theme); + setTheme(data.theme); } return data; @@ -39,7 +39,7 @@ export async function saveUserSettings(partial: UserSettingsUpdate): Promise { formData.theme = theme.value; showThemeDropdown = false; - // Apply theme immediately if it's the theme setting if (theme.value) { - themeStore.set(theme.value); + setTheme(theme.value); } }} class:selected={formData.theme === theme.value} diff --git a/frontend/src/stores/__tests__/theme.test.ts b/frontend/src/stores/__tests__/theme.test.ts index 16d2cdf..03cf83f 100644 --- a/frontend/src/stores/__tests__/theme.test.ts +++ b/frontend/src/stores/__tests__/theme.test.ts @@ -147,26 +147,6 @@ describe('theme store', () => { }); }); - describe('setThemeLocal', () => { - it('updates store without triggering server save', async () => { - const { theme, setThemeLocal } = await import('../theme'); - - setThemeLocal('dark'); - expect(get(theme)).toBe('dark'); - expect(localStorage.setItem).toHaveBeenCalledWith('app-theme', 'dark'); - }); - - it('applies theme to DOM', async () => { - const { setThemeLocal } = await import('../theme'); - - setThemeLocal('dark'); - expect(document.documentElement.classList.contains('dark')).toBe(true); - - setThemeLocal('light'); - expect(document.documentElement.classList.contains('dark')).toBe(false); - }); - }); - describe('auto theme', () => { it('applies light when system prefers light', async () => { vi.mocked(matchMedia).mockImplementation((query: string) => ({ diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index e97a645..fa14f07 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -43,9 +43,6 @@ export const theme = { if (browser) { localStorage.setItem(storageKey, value); } - if (saveUserSettings && isAuthenticatedStore && get(isAuthenticatedStore)) { - saveUserSettings({ theme: value }); - } }, update }; @@ -72,16 +69,11 @@ export function toggleTheme(): void { const current = get(theme); const next: ThemeValue = current === 'light' ? 'dark' : current === 'dark' ? 'auto' : 'light'; theme.set(next); + if (saveUserSettings && isAuthenticatedStore && get(isAuthenticatedStore)) { + saveUserSettings({ theme: next }); + } } export function setTheme(newTheme: ThemeValue): void { theme.set(newTheme); } - -export function setThemeLocal(newTheme: ThemeValue): void { - internalSet(newTheme); - if (browser) { - localStorage.setItem(storageKey, newTheme); - } - applyTheme(newTheme); -} From 053b4b53047f040db44f702eea22d2b798a13bb1 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 20:26:53 +0100 Subject: [PATCH 09/17] fixed logging --- frontend/src/main.ts | 4 +++- frontend/src/routes/Settings.svelte | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 179d85f..50755c8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -15,7 +15,9 @@ window.onerror = (message, source, lineno, colno, error) => { }; window.onunhandledrejection = (event) => { - console.info('[Promise Rejection Handled]', event.reason); + console.error('[Unhandled Promise Rejection]', event.reason); + const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason)); + appError.setError(error, 'Unexpected Error'); event.preventDefault(); }; diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index 06b366a..89a5af9 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -108,7 +108,7 @@ execution_failed: settings.notifications?.execution_failed ?? true, system_updates: settings.notifications?.system_updates ?? true, security_alerts: settings.notifications?.security_alerts ?? true, - channels: ['in_app'] + channels: settings.notifications?.channels || ['in_app'] }, editor: { theme: settings.editor?.theme || 'auto', From dc8f708822e725ff1ec27f285d5a4714bfdcfd02 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 20:35:46 +0100 Subject: [PATCH 10/17] csrf fix: local storage -> session storage --- frontend/src/stores/__tests__/auth.test.ts | 46 +++++++--------------- frontend/src/stores/auth.ts | 13 ++---- 2 files changed, 18 insertions(+), 41 deletions(-) diff --git a/frontend/src/stores/__tests__/auth.test.ts b/frontend/src/stores/__tests__/auth.test.ts index 9e454ba..e2b9faf 100644 --- a/frontend/src/stores/__tests__/auth.test.ts +++ b/frontend/src/stores/__tests__/auth.test.ts @@ -16,17 +16,17 @@ vi.mock('../../lib/api', () => ({ })); describe('auth store', () => { - let localStorageData: Record = {}; + let sessionStorageData: Record = {}; beforeEach(async () => { - // Reset localStorage mock - localStorageData = {}; - vi.mocked(localStorage.getItem).mockImplementation((key: string) => localStorageData[key] ?? null); - vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => { - localStorageData[key] = value; + // Reset sessionStorage mock + sessionStorageData = {}; + vi.mocked(sessionStorage.getItem).mockImplementation((key: string) => sessionStorageData[key] ?? null); + vi.mocked(sessionStorage.setItem).mockImplementation((key: string, value: string) => { + sessionStorageData[key] = value; }); - vi.mocked(localStorage.removeItem).mockImplementation((key: string) => { - delete localStorageData[key]; + vi.mocked(sessionStorage.removeItem).mockImplementation((key: string) => { + delete sessionStorageData[key]; }); // Reset all mocks @@ -59,7 +59,7 @@ describe('auth store', () => { expect(get(userRole)).toBe(null); }); - it('restores auth state from localStorage', async () => { + it('restores auth state from sessionStorage', async () => { const authState = { isAuthenticated: true, username: 'testuser', @@ -69,7 +69,7 @@ describe('auth store', () => { userEmail: 'test@example.com', timestamp: Date.now(), }; - localStorageData['authState'] = JSON.stringify(authState); + sessionStorageData['authState'] = JSON.stringify(authState); const { isAuthenticated, username, userRole, csrfToken } = await import('../auth'); @@ -78,24 +78,6 @@ describe('auth store', () => { expect(get(userRole)).toBe('user'); expect(get(csrfToken)).toBe('test-token'); }); - - it('clears expired auth state from localStorage', async () => { - const authState = { - isAuthenticated: true, - username: 'testuser', - userRole: 'user', - csrfToken: 'test-token', - userId: 'user-123', - userEmail: 'test@example.com', - timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago - }; - localStorageData['authState'] = JSON.stringify(authState); - - const { isAuthenticated } = await import('../auth'); - - expect(get(isAuthenticated)).toBe(null); - expect(localStorage.removeItem).toHaveBeenCalledWith('authState'); - }); }); describe('login', () => { @@ -124,7 +106,7 @@ describe('auth store', () => { expect(get(csrfToken)).toBe('new-csrf-token'); }); - it('persists auth state to localStorage on login', async () => { + it('persists auth state to sessionStorage on login', async () => { mockLoginApi.mockResolvedValue({ data: { username: 'testuser', @@ -141,7 +123,7 @@ describe('auth store', () => { const { login } = await import('../auth'); await login('testuser', 'password123'); - expect(localStorage.setItem).toHaveBeenCalledWith( + expect(sessionStorage.setItem).toHaveBeenCalledWith( 'authState', expect.stringContaining('testuser') ); @@ -201,13 +183,13 @@ describe('auth store', () => { expect(get(username)).toBe(null); }); - it('clears localStorage on logout', async () => { + it('clears sessionStorage on logout', async () => { mockLogoutApi.mockResolvedValue({ data: {}, error: null }); const { logout } = await import('../auth'); await logout(); - expect(localStorage.removeItem).toHaveBeenCalledWith('authState'); + expect(sessionStorage.removeItem).toHaveBeenCalledWith('authState'); }); it('still clears state even if API call fails', async () => { diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index d456d0a..7633a75 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -19,24 +19,19 @@ interface AuthState { function getPersistedAuthState(): AuthState | null { if (typeof window === 'undefined') return null; try { - const data = localStorage.getItem('authState'); + const data = sessionStorage.getItem('authState'); if (!data) return null; - const parsed = JSON.parse(data) as AuthState; - if (Date.now() - parsed.timestamp > 24 * 60 * 60 * 1000) { - localStorage.removeItem('authState'); - return null; - } - return parsed; + return JSON.parse(data) as AuthState; } catch { return null; } } function persistAuthState(state: Partial | null) { if (typeof window === 'undefined') return; if (!state || state.isAuthenticated === false) { - localStorage.removeItem('authState'); + sessionStorage.removeItem('authState'); return; } - localStorage.setItem('authState', JSON.stringify({ ...state, timestamp: Date.now() })); + sessionStorage.setItem('authState', JSON.stringify({ ...state, timestamp: Date.now() })); } const persisted = getPersistedAuthState(); From d13a257c7f171882a34ce6c4cbc13afb1351d9d0 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 20:55:15 +0100 Subject: [PATCH 11/17] csrf fix: local storage -> session storage --- frontend/src/lib/__tests__/auth-init.test.ts | 45 +++++++------------- frontend/src/lib/api-interceptors.ts | 2 +- frontend/src/lib/auth-init.ts | 14 ++---- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/__tests__/auth-init.test.ts b/frontend/src/lib/__tests__/auth-init.test.ts index 86b7ef9..a4c9caa 100644 --- a/frontend/src/lib/__tests__/auth-init.test.ts +++ b/frontend/src/lib/__tests__/auth-init.test.ts @@ -55,20 +55,20 @@ function setupMatchMedia() { } describe('auth-init', () => { - let localStorageData: Record = {}; + let sessionStorageData: Record = {}; beforeEach(async () => { // Setup matchMedia before module imports (must happen after resetModules) setupMatchMedia(); - // Reset localStorage mock - localStorageData = {}; - vi.mocked(localStorage.getItem).mockImplementation((key: string) => localStorageData[key] ?? null); - vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => { - localStorageData[key] = value; + // Reset sessionStorage mock + sessionStorageData = {}; + vi.mocked(sessionStorage.getItem).mockImplementation((key: string) => sessionStorageData[key] ?? null); + vi.mocked(sessionStorage.setItem).mockImplementation((key: string, value: string) => { + sessionStorageData[key] = value; }); - vi.mocked(localStorage.removeItem).mockImplementation((key: string) => { - delete localStorageData[key]; + vi.mocked(sessionStorage.removeItem).mockImplementation((key: string) => { + delete sessionStorageData[key]; }); // Reset all mocks @@ -163,7 +163,7 @@ describe('auth-init', () => { csrfToken: 'csrf-token', timestamp: Date.now(), }; - localStorageData['authState'] = JSON.stringify(authState); + sessionStorageData['authState'] = JSON.stringify(authState); mockVerifyAuth.mockResolvedValue(true); const { AuthInitializer } = await import('../auth-init'); @@ -187,29 +187,14 @@ describe('auth-init', () => { csrfToken: 'token', timestamp: Date.now(), }; - localStorageData['authState'] = JSON.stringify(authState); + sessionStorageData['authState'] = JSON.stringify(authState); mockVerifyAuth.mockResolvedValue(false); const { AuthInitializer } = await import('../auth-init'); await AuthInitializer.initialize(); expect(mockIsAuthenticatedSet).toHaveBeenLastCalledWith(false); - expect(localStorage.removeItem).toHaveBeenCalledWith('authState'); - }); - - it('removes expired persisted auth (>24 hours)', async () => { - const authState = { - isAuthenticated: true, - username: 'testuser', - timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago - }; - localStorageData['authState'] = JSON.stringify(authState); - mockVerifyAuth.mockResolvedValue(false); - - const { AuthInitializer } = await import('../auth-init'); - await AuthInitializer.initialize(); - - expect(localStorage.removeItem).toHaveBeenCalledWith('authState'); + expect(sessionStorage.removeItem).toHaveBeenCalledWith('authState'); }); it('keeps recent auth on network error (<5 min)', async () => { @@ -222,7 +207,7 @@ describe('auth-init', () => { csrfToken: 'token', timestamp: Date.now() - 2 * 60 * 1000, // 2 minutes ago }; - localStorageData['authState'] = JSON.stringify(authState); + sessionStorageData['authState'] = JSON.stringify(authState); mockVerifyAuth.mockRejectedValue(new Error('Network error')); const { AuthInitializer } = await import('../auth-init'); @@ -241,7 +226,7 @@ describe('auth-init', () => { csrfToken: 'token', timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago }; - localStorageData['authState'] = JSON.stringify(authState); + sessionStorageData['authState'] = JSON.stringify(authState); mockVerifyAuth.mockRejectedValue(new Error('Network error')); const { AuthInitializer } = await import('../auth-init'); @@ -348,8 +333,8 @@ describe('auth-init', () => { }); describe('error handling', () => { - it('handles malformed JSON in localStorage', async () => { - localStorageData['authState'] = 'not valid json{'; + it('handles malformed JSON in sessionStorage', async () => { + sessionStorageData['authState'] = 'not valid json{'; mockVerifyAuth.mockResolvedValue(false); const { AuthInitializer } = await import('../auth-init'); diff --git a/frontend/src/lib/api-interceptors.ts b/frontend/src/lib/api-interceptors.ts index ab9a347..4dfc2a9 100644 --- a/frontend/src/lib/api-interceptors.ts +++ b/frontend/src/lib/api-interceptors.ts @@ -46,7 +46,7 @@ function clearAuthState(): void { userRole.set(null); userEmail.set(null); csrfToken.set(null); - localStorage.removeItem('authState'); + sessionStorage.removeItem('authState'); } function handleAuthFailure(currentPath: string): void { diff --git a/frontend/src/lib/auth-init.ts b/frontend/src/lib/auth-init.ts index 31433f8..4b24c6b 100644 --- a/frontend/src/lib/auth-init.ts +++ b/frontend/src/lib/auth-init.ts @@ -127,17 +127,9 @@ export class AuthInitializer { private static _getPersistedAuth(): PersistedAuth | null { try { - const authData = localStorage.getItem('authState'); + const authData = sessionStorage.getItem('authState'); if (!authData) return null; - - const parsed: PersistedAuth = JSON.parse(authData); - - if (Date.now() - parsed.timestamp > 24 * 60 * 60 * 1000) { - localStorage.removeItem('authState'); - return null; - } - - return parsed; + return JSON.parse(authData); } catch (e) { console.error('[AuthInit] Failed to parse persisted auth:', e); return null; @@ -156,7 +148,7 @@ export class AuthInitializer { userEmail.set(null); csrfToken.set(null); clearUserSettings(); - localStorage.removeItem('authState'); + sessionStorage.removeItem('authState'); } static isAuthenticated(): boolean { From db44f2b74e62dccbf89277ed4197220cab48749d Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 22:14:36 +0100 Subject: [PATCH 12/17] icons lib instead of hardcoded svgs --- frontend/src/components/ErrorDisplay.svelte | 6 +- frontend/src/components/Footer.svelte | 30 +-- frontend/src/components/Header.svelte | 37 ++- .../src/components/NotificationCenter.svelte | 27 +-- frontend/src/components/ToastContainer.svelte | 17 +- .../src/components/__tests__/Header.test.ts | 10 +- .../__tests__/NotificationCenter.test.ts | 23 +- frontend/src/routes/Editor.svelte | 221 +++++++++--------- frontend/src/routes/Home.svelte | 17 +- frontend/src/routes/Notifications.svelte | 31 +-- frontend/src/routes/Settings.svelte | 9 +- frontend/src/routes/admin/AdminLayout.svelte | 5 +- 12 files changed, 196 insertions(+), 237 deletions(-) diff --git a/frontend/src/components/ErrorDisplay.svelte b/frontend/src/components/ErrorDisplay.svelte index fe9da4a..01d85d1 100644 --- a/frontend/src/components/ErrorDisplay.svelte +++ b/frontend/src/components/ErrorDisplay.svelte @@ -1,4 +1,6 @@
diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte index 04c90fa..7ee7d71 100644 --- a/frontend/src/components/Header.svelte +++ b/frontend/src/components/Header.svelte @@ -5,24 +5,13 @@ import { fade } from 'svelte/transition'; import { onMount, onDestroy } from 'svelte'; import NotificationCenter from './NotificationCenter.svelte'; + import { Sun, Moon, MonitorCog, Menu, X, LogIn, UserPlus, LogOut, User, ChevronDown, Settings } from '@lucide/svelte'; let isMenuActive = $state(false); let isMobile = $state(false); let resizeListener: (() => void) | null = null; let showUserDropdown = $state(false); - - const sunIcon = ``; - const moonIcon = ``; - const autoIcon = ``; - const menuIcon = ``; - const closeIcon = ``; let logoImgClass = "h-8 max-h-8 w-auto transition-all duration-200 group-hover:scale-110"; - const loginIcon = ``; - const registerIcon = ``; - const logoutIcon = ``; - const userIcon = ``; - const chevronDownIcon = ``; - const adminIcon = ``; function toggleMenu() { @@ -107,11 +96,11 @@
@@ -125,10 +114,10 @@ class="flex items-center space-x-2 btn btn-ghost btn-sm" >
- {@html userIcon} + - {@html chevronDownIcon} +
@@ -200,7 +189,11 @@
@@ -226,21 +219,21 @@ {#if $userRole === 'admin'} - {@html adminIcon} Admin Panel + Admin Panel {/if} Settings {:else} - {@html loginIcon} Login + Login - {@html registerIcon} Register + Register {/if} diff --git a/frontend/src/components/NotificationCenter.svelte b/frontend/src/components/NotificationCenter.svelte index 543516e..cd1ba44 100644 --- a/frontend/src/components/NotificationCenter.svelte +++ b/frontend/src/components/NotificationCenter.svelte @@ -1,11 +1,12 @@ @@ -170,8 +163,8 @@ {:else if $notifications.length === 0}
-
- {@html bellIcon} +
+

No notifications yet @@ -196,7 +189,7 @@
- {@html getNotificationIcon(notification.tags)} +
@@ -218,7 +211,7 @@ {#if deleting[notification.notification_id]} {:else} - {@html trashIcon} + {/if} {#if (notification.tags || []).some(t => t.startsWith('exec:'))} @@ -237,7 +230,7 @@
- {@html clockIcon} + {formatTimestamp(notification.created_at)} diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index 89a5af9..078f4ee 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -13,6 +13,7 @@ import { fly } from 'svelte/transition'; import { setUserSettings } from '../stores/userSettings'; import Spinner from '../components/Spinner.svelte'; + import { ChevronDown } from '@lucide/svelte'; let settings = $state(null); let loading = $state(true); @@ -312,9 +313,7 @@ class="form-dropdown-button" aria-expanded={showThemeDropdown}> {themes.find(t => t.value === formData.theme)?.label || 'Select theme'} - - - + {#if showThemeDropdown} @@ -355,9 +354,7 @@ class="form-dropdown-button" aria-expanded={showEditorThemeDropdown}> {editorThemes.find(t => t.value === formData.editor.theme)?.label || 'Select theme'} - - - + {#if showEditorThemeDropdown} diff --git a/frontend/src/routes/admin/AdminLayout.svelte b/frontend/src/routes/admin/AdminLayout.svelte index 8a54f2b..a7808df 100644 --- a/frontend/src/routes/admin/AdminLayout.svelte +++ b/frontend/src/routes/admin/AdminLayout.svelte @@ -6,6 +6,7 @@ import { get } from 'svelte/store'; import Spinner from '../../components/Spinner.svelte'; import type { Snippet } from 'svelte'; + import { ShieldCheck } from '@lucide/svelte'; let { path = '', children }: { path?: string; children?: Snippet } = $props(); @@ -78,9 +79,7 @@
- - - +

Admin Panel

From 35d22bf80d11814d042bd00e2a291a60f03013cc Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 25 Dec 2025 23:33:22 +0100 Subject: [PATCH 13/17] editor page refactoring --- frontend/rollup.config.js | 4 +- .../components/editor/CodeMirrorEditor.svelte | 127 ++ .../components/editor/EditorToolbar.svelte | 26 + .../components/editor/LanguageSelect.svelte | 69 + .../src/components/editor/OutputPanel.svelte | 171 +++ .../components/editor/ResourceLimits.svelte | 45 + .../src/components/editor/SavedScripts.svelte | 74 + .../components/editor/ScriptActions.svelte | 37 + frontend/src/components/editor/index.ts | 7 + frontend/src/lib/api-interceptors.ts | 34 +- frontend/src/lib/editor/execution.svelte.ts | 113 ++ frontend/src/lib/editor/index.ts | 2 + frontend/src/lib/editor/languages.ts | 19 + frontend/src/routes/Editor.svelte | 1212 +++-------------- frontend/src/styles/pages.css | 12 +- 15 files changed, 914 insertions(+), 1038 deletions(-) create mode 100644 frontend/src/components/editor/CodeMirrorEditor.svelte create mode 100644 frontend/src/components/editor/EditorToolbar.svelte create mode 100644 frontend/src/components/editor/LanguageSelect.svelte create mode 100644 frontend/src/components/editor/OutputPanel.svelte create mode 100644 frontend/src/components/editor/ResourceLimits.svelte create mode 100644 frontend/src/components/editor/SavedScripts.svelte create mode 100644 frontend/src/components/editor/ScriptActions.svelte create mode 100644 frontend/src/components/editor/index.ts create mode 100644 frontend/src/lib/editor/execution.svelte.ts create mode 100644 frontend/src/lib/editor/index.ts create mode 100644 frontend/src/lib/editor/languages.ts diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index 8cf1e14..de4891c 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -144,6 +144,8 @@ export default { '@codemirror/language', '@codemirror/autocomplete', '@codemirror/lang-python', + '@codemirror/lang-javascript', + '@codemirror/lang-go', '@codemirror/theme-one-dark', '@uiw/codemirror-theme-github' ] @@ -182,7 +184,7 @@ export default { // Prefer ES modules mainFields: ['svelte', 'module', 'browser', 'main'], exportConditions: ['svelte'], - extensions: ['.mjs', '.js', '.json', '.node', '.svelte'] + extensions: ['.mjs', '.js', '.ts', '.json', '.node', '.svelte'] }), commonjs(), !production && { diff --git a/frontend/src/components/editor/CodeMirrorEditor.svelte b/frontend/src/components/editor/CodeMirrorEditor.svelte new file mode 100644 index 0000000..f90260d --- /dev/null +++ b/frontend/src/components/editor/CodeMirrorEditor.svelte @@ -0,0 +1,127 @@ + + +
diff --git a/frontend/src/components/editor/EditorToolbar.svelte b/frontend/src/components/editor/EditorToolbar.svelte new file mode 100644 index 0000000..e8c43a1 --- /dev/null +++ b/frontend/src/components/editor/EditorToolbar.svelte @@ -0,0 +1,26 @@ + + +
+
+ + onchange(e.currentTarget.value)} /> +
+
+ +
+
diff --git a/frontend/src/components/editor/LanguageSelect.svelte b/frontend/src/components/editor/LanguageSelect.svelte new file mode 100644 index 0000000..d9ed201 --- /dev/null +++ b/frontend/src/components/editor/LanguageSelect.svelte @@ -0,0 +1,69 @@ + + +
+ + + {#if showOptions && available} +
+
    hoveredLang = null}> + {#each Object.entries(runtimes) as [l, info] (l)} +
  • hoveredLang = l}> +
    + {l} + +
    + + {#if hoveredLang === l && info.versions.length > 0} +
    +
      + {#each info.versions as v (v)} +
    • + +
    • + {/each} +
    +
    + {/if} +
  • + {/each} +
+
+ {/if} +
diff --git a/frontend/src/components/editor/OutputPanel.svelte b/frontend/src/components/editor/OutputPanel.svelte new file mode 100644 index 0000000..f29d621 --- /dev/null +++ b/frontend/src/components/editor/OutputPanel.svelte @@ -0,0 +1,171 @@ + + +
+

+ Execution Output +

+
+ {#if phase !== 'idle'} +
+ +

{phaseLabel}

+
+ {:else if error && !result} +
+
+ +
+

Execution Failed

+

{error}

+
+ {:else if result} +
+
+ + Status: {result.status} + + + {#if result.execution_id} +
+ +
+ Execution ID:
{result.execution_id}
Click to copy +
+
+ {/if} +
+ + {#if result.stdout} +
+

Output:

+
+
{@html sanitize(ansiConverter.toHtml(result.stdout || ''))}
+
+ +
+ Copy output +
+
+
+
+ {/if} + + {#if result.stderr} +
+

Errors:

+
+
+
{@html sanitize(ansiConverter.toHtml(result.stderr || ''))}
+
+
+ +
+ Copy errors +
+
+
+
+ {/if} + + {#if result.resource_usage} +
+

Resource Usage:

+
+
+ CPU: + + {result.resource_usage.cpu_time_jiffies === 0 + ? '< 10 m' + : `${((result.resource_usage.cpu_time_jiffies ?? 0) * 10).toFixed(3)} m`} + +
+
+ Memory: + + {`${((result.resource_usage.peak_memory_kb ?? 0) / 1024).toFixed(3)} MiB`} + +
+
+ Time: + + {`${(result.resource_usage.execution_time_wall_seconds ?? 0).toFixed(3)} s`} + +
+
+
+ {/if} +
+ {:else} +
+ Write some code and click "Run Script" to see the output. +
+ {/if} +
+
diff --git a/frontend/src/components/editor/ResourceLimits.svelte b/frontend/src/components/editor/ResourceLimits.svelte new file mode 100644 index 0000000..6ffbf40 --- /dev/null +++ b/frontend/src/components/editor/ResourceLimits.svelte @@ -0,0 +1,45 @@ + + +{#if limits} +
+ + {#if show} +
+
+
+ + CPU Limit + + {limits.cpu_limit} +
+
+ + Memory Limit + + {limits.memory_limit} +
+
+ + Timeout + + {limits.execution_timeout}s +
+
+
+ {/if} +
+{/if} diff --git a/frontend/src/components/editor/SavedScripts.svelte b/frontend/src/components/editor/SavedScripts.svelte new file mode 100644 index 0000000..b9dfc97 --- /dev/null +++ b/frontend/src/components/editor/SavedScripts.svelte @@ -0,0 +1,74 @@ + + +
+

Saved Scripts

+
+ +
+ {#if show} +
+ {#if scripts.length > 0} +
+
    + {#each scripts as item (item.id)} +
  • + + +
  • + {/each} +
+
+ {:else} +

+ No saved scripts yet. +

+ {/if} +
+ {/if} +
diff --git a/frontend/src/components/editor/ScriptActions.svelte b/frontend/src/components/editor/ScriptActions.svelte new file mode 100644 index 0000000..75101d9 --- /dev/null +++ b/frontend/src/components/editor/ScriptActions.svelte @@ -0,0 +1,37 @@ + + +
+

File Actions

+
+ + + {#if authenticated} + + {/if} + +
+
diff --git a/frontend/src/components/editor/index.ts b/frontend/src/components/editor/index.ts new file mode 100644 index 0000000..1ea37e0 --- /dev/null +++ b/frontend/src/components/editor/index.ts @@ -0,0 +1,7 @@ +export { default as CodeMirrorEditor } from './CodeMirrorEditor.svelte'; +export { default as OutputPanel } from './OutputPanel.svelte'; +export { default as LanguageSelect } from './LanguageSelect.svelte'; +export { default as ResourceLimits } from './ResourceLimits.svelte'; +export { default as EditorToolbar } from './EditorToolbar.svelte'; +export { default as ScriptActions } from './ScriptActions.svelte'; +export { default as SavedScripts } from './SavedScripts.svelte'; diff --git a/frontend/src/lib/api-interceptors.ts b/frontend/src/lib/api-interceptors.ts index 4dfc2a9..8e51698 100644 --- a/frontend/src/lib/api-interceptors.ts +++ b/frontend/src/lib/api-interceptors.ts @@ -70,16 +70,25 @@ export function initializeApiInterceptors(): void { console.error('[API Error]', { status, url, error }); - if (status === 401 && !isAuthEndpoint && !isHandling401) { - isHandling401 = true; - try { - const currentPath = window.location.pathname + window.location.search; - addToast('Session expired. Please log in again.', 'warning'); - handleAuthFailure(currentPath); - } finally { - setTimeout(() => { isHandling401 = false; }, 1000); + // 401: Silent by default. Only show toast + redirect if user HAD an active session. + if (status === 401) { + if (isAuthEndpoint) { + return error; // Auth endpoints handle their own messaging } - return { _handled: true, _status: 401 }; + const wasAuthenticated = get(isAuthenticated); + if (wasAuthenticated && !isHandling401) { + isHandling401 = true; + try { + const currentPath = window.location.pathname + window.location.search; + addToast('Session expired. Please log in again.', 'warning'); + handleAuthFailure(currentPath); + } finally { + setTimeout(() => { isHandling401 = false; }, 1000); + } + } else { + clearAuthState(); + } + return error; } if (status === 403) { @@ -105,12 +114,15 @@ export function initializeApiInterceptors(): void { return error; } - if (!response && !url.includes('/verify-token')) { + if (!response && !isAuthEndpoint) { addToast('Network error. Check your connection.', 'error'); return error; } - addToast(getErrorMessage(error, 'An error occurred'), 'error'); + // Don't toast for auth-related silent failures + if (!isAuthEndpoint) { + addToast(getErrorMessage(error, 'An error occurred'), 'error'); + } return error; }); diff --git a/frontend/src/lib/editor/execution.svelte.ts b/frontend/src/lib/editor/execution.svelte.ts new file mode 100644 index 0000000..183cd8b --- /dev/null +++ b/frontend/src/lib/editor/execution.svelte.ts @@ -0,0 +1,113 @@ +import { + createExecutionApiV1ExecutePost, + getResultApiV1ResultExecutionIdGet, + type ExecutionResult, +} from '../api'; +import { getErrorMessage } from '../api-interceptors'; + +export type ExecutionPhase = 'idle' | 'starting' | 'queued' | 'scheduled' | 'running'; + +export function createExecutionState() { + let phase = $state('idle'); + let result = $state(null); + let error = $state(null); + + function reset() { + phase = 'idle'; + result = null; + error = null; + } + + async function execute(script: string, lang: string, langVersion: string): Promise { + reset(); + phase = 'starting'; + let executionId: string | null = null; + + try { + const { data, error: execError } = await createExecutionApiV1ExecutePost({ + body: { script, lang, lang_version: langVersion } + }); + if (execError) throw execError; + + executionId = data.execution_id; + phase = (data.status as ExecutionPhase) || 'queued'; + + const finalResult = await new Promise((resolve, reject) => { + const eventSource = new EventSource(`/api/v1/events/executions/${executionId}`, { + withCredentials: true + }); + + const fetchFallback = async () => { + try { + const { data, error } = await getResultApiV1ResultExecutionIdGet({ + path: { execution_id: executionId! } + }); + if (error) throw error; + resolve(data!); + } catch (e) { + reject(e); + } + }; + + eventSource.onmessage = async (event) => { + try { + const eventData = JSON.parse(event.data); + const eventType = eventData?.event_type || eventData?.type; + + if (eventType === 'heartbeat' || eventType === 'connected') return; + + if (eventData.status) { + phase = eventData.status as ExecutionPhase; + } + + if (eventType === 'result_stored' && eventData.result) { + eventSource.close(); + resolve(eventData.result); + return; + } + + if (['execution_failed', 'execution_timeout', 'result_failed'].includes(eventType)) { + eventSource.close(); + await fetchFallback(); + } + } catch (err) { + console.error('SSE parse error:', err); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + fetchFallback(); + }; + }); + + result = finalResult; + } catch (err) { + error = getErrorMessage(err, 'Error executing script.'); + if (executionId) { + try { + const { data } = await getResultApiV1ResultExecutionIdGet({ + path: { execution_id: executionId } + }); + if (data) { + result = data; + error = null; + } + } catch { /* keep error */ } + } + } finally { + phase = 'idle'; + } + } + + return { + get phase() { return phase; }, + get result() { return result; }, + get error() { return error; }, + get isExecuting() { return phase !== 'idle'; }, + execute, + reset + }; +} + +export type ExecutionState = ReturnType; diff --git a/frontend/src/lib/editor/index.ts b/frontend/src/lib/editor/index.ts new file mode 100644 index 0000000..179dc48 --- /dev/null +++ b/frontend/src/lib/editor/index.ts @@ -0,0 +1,2 @@ +export { getLanguageExtension } from './languages'; +export { createExecutionState, type ExecutionState, type ExecutionPhase } from './execution.svelte'; diff --git a/frontend/src/lib/editor/languages.ts b/frontend/src/lib/editor/languages.ts new file mode 100644 index 0000000..158b9cd --- /dev/null +++ b/frontend/src/lib/editor/languages.ts @@ -0,0 +1,19 @@ +import { python } from '@codemirror/lang-python'; +import { javascript } from '@codemirror/lang-javascript'; +import { go } from '@codemirror/lang-go'; +import { StreamLanguage, type LanguageSupport } from '@codemirror/language'; +import { ruby } from '@codemirror/legacy-modes/mode/ruby'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import type { Extension } from '@codemirror/state'; + +const languageExtensions: Record LanguageSupport | Extension> = { + python: () => python(), + node: () => javascript(), + go: () => go(), + ruby: () => StreamLanguage.define(ruby), + bash: () => StreamLanguage.define(shell), +}; + +export function getLanguageExtension(lang: string): LanguageSupport | Extension { + return languageExtensions[lang]?.() ?? python(); +} diff --git a/frontend/src/routes/Editor.svelte b/frontend/src/routes/Editor.svelte index 22f93df..137dd26 100644 --- a/frontend/src/routes/Editor.svelte +++ b/frontend/src/routes/Editor.svelte @@ -1,24 +1,36 @@ - +
-

- Code Editor -

- {#if k8sLimits} -
- - {#if showLimits} -
-
-
- CPU Limit - {k8sLimits.cpu_limit} -
-
- Memory Limit - {k8sLimits.memory_limit} -
-
- Timeout - {k8sLimits.execution_timeout} - s -
-
-
- {/if} -
- {:else if apiError && !k8sLimits} -

{apiError}

- {/if} +

Code Editor

+
-
-
- - -
-
- -
-
-
- {#if !editorView} -
- - Loading Editor... -
- {:else if get(script).trim() === ''} -
+ scriptName.set(n)} onexample={loadExampleScript} /> +
+ + {#if get(script).trim() === ''} +

Editor is Empty

-

- Start typing, upload a file, or use an example to begin. -

- @@ -878,225 +314,27 @@
-
-

- Execution Output -

-
- {#if executing} -
- -

- {#if executionStatus} - {executionStatus === 'queued' ? 'Queued...' : - executionStatus === 'running' ? 'Running...' : - executionStatus === 'scheduled' ? 'Scheduled...' : - 'Executing...'} - {:else} - Executing... - {/if} -

-
- {:else if apiError && !result} -
-
- -
-

Execution Failed

-

{apiError}

-
- {:else if result} -
-
- Status: {result.status} - - {#if result.execution_id} -
- -
- Execution ID: -
{result.execution_id} -
Click to copy -
-
- {/if} -
- - {#if result.stdout} -
-

- Output:

-
-
{@html sanitizeOutput(ansiConverter.toHtml(result.stdout || ''))}
-
- -
- Copy output -
-
-
-
- {/if} - - {#if result.stderr} -
-

- Errors:

-
-
-
{@html sanitizeOutput(ansiConverter.toHtml(result.stderr || ''))}
-
-
- -
- Copy errors -
-
-
-
- {/if} - - {#if result.resource_usage} -
-

- Resource Usage:

-
-
- CPU: - - {result.resource_usage.cpu_time_jiffies === 0 - ? '< 10 m' - : `${(result.resource_usage.cpu_time_jiffies * 10).toFixed(3)} m` ?? 'N/A'} - -
-
- Memory: - - {`${(result.resource_usage.peak_memory_kb / 1024).toFixed(3)} MiB` ?? 'N/A'} - -
-
- Time: - - {`${result.resource_usage.execution_time_wall_seconds.toFixed(3)} s` ?? 'N/A'} - -
-
-
- {/if} -
- {:else} -
- Write some code and click "Run Script" to see the output. -
- {/if} -
-
+
- -
- - - {#if showLangOptions && runtimesAvailable} -
-
    hoveredLang = null}> - {#each Object.entries(supportedRuntimes) as [lang, langInfo] (lang)} -
  • hoveredLang = lang}> -
    - {lang} - -
    - - {#if hoveredLang === lang && langInfo.versions.length > 0} -
    -
      - {#each langInfo.versions as version (version)} -
    • - -
    • - {/each} -
    -
    - {/if} -
  • - {/each} -
-
- {/if} -
- - - {#if authenticated} - - {/if} - -
+
+ fileInput.click()} + onsave={saveScript} + onexport={exportScript} + />
- -
- - -
+
{#if authenticated} -

- Saved Scripts -

-
- -
- {#if showSavedScripts} -
- {#if savedScripts.length > 0} -
-
    - {#each savedScripts as savedItem, index (savedItem.id || index)} -
  • - - -
  • - {/each} -
-
- {:else} -

- No saved scripts yet.

- {/if} -
- {/if} + {:else} -
-

- Saved Scripts -

+
+

Saved Scripts

Log in to save and manage your scripts.

diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index 7f448d0..9f4a32b 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -20,13 +20,12 @@ margin-right: auto; position: relative; min-height: calc(100vh - 8rem); - max-height: calc(100vh - 8rem); - padding: 1rem 1rem 0 1rem; + padding: 1rem 1rem 1rem 1rem; } @media (min-width: 640px) { .editor-grid-container { - padding: 1.5rem 1.5rem 0 1.5rem; + padding: 1.5rem 1.5rem 1rem 1.5rem; } } @@ -85,8 +84,7 @@ grid-template-rows: auto minmax(400px, 1fr) auto; gap: 1rem; min-height: calc(100vh - 8rem); - max-height: calc(100vh - 8rem); - padding: 1.5rem 1.5rem 0 1.5rem; + padding: 1.5rem 1.5rem 1rem 1.5rem; } .editor-header { @@ -112,13 +110,13 @@ @media (min-width: 1024px) { .editor-grid-container { - padding: 2rem 2rem 0 2rem; + padding: 2rem 2rem 1rem 2rem; } } @media (min-width: 1280px) { .editor-grid-container { - padding: 2rem 3rem 0 3rem; + padding: 2rem 3rem 1rem 3rem; } } From 71055a4e1ff3a6e6e8c1950ebc0e135f182c1c56 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 26 Dec 2025 00:14:12 +0100 Subject: [PATCH 14/17] - tests for new notifications - keyboard navi support in editor - smaller fixes --- .../__tests__/NotificationCenter.test.ts | 67 ++++++++++- .../components/editor/LanguageSelect.svelte | 109 ++++++++++++++++-- .../src/components/editor/OutputPanel.svelte | 3 +- frontend/src/routes/Editor.svelte | 23 +++- 4 files changed, 184 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index 9a84cd3..b6eaae6 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -124,7 +124,14 @@ class MockEventSource { static getLastInstance() { return MockEventSource.instances[MockEventSource.instances.length - 1]; } } vi.stubGlobal('EventSource', MockEventSource); -vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('granted') }); + +// Configurable Notification mock +const mockRequestPermission = vi.fn().mockResolvedValue('granted'); +let mockNotificationPermission = 'default'; +vi.stubGlobal('Notification', { + get permission() { return mockNotificationPermission; }, + requestPermission: mockRequestPermission, +}); import NotificationCenter from '../NotificationCenter.svelte'; @@ -262,6 +269,8 @@ describe('NotificationCenter', () => { mocks.mockNotificationStore.clear.mockReset(); mocks.mockNotificationStore.add.mockReset(); MockEventSource.clearInstances(); + mockNotificationPermission = 'default'; + mockRequestPermission.mockReset().mockResolvedValue('granted'); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'debug').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -513,4 +522,60 @@ describe('NotificationCenter', () => { vi.useRealTimers(); }); }); + + describe('desktop notification permission', () => { + it('shows enable button when permission is default', async () => { + mockNotificationPermission = 'default'; + await openDropdown(); + await waitFor(() => { + expect(screen.getByText('Enable desktop notifications')).toBeInTheDocument(); + }); + }); + + it('hides enable button when permission is granted', async () => { + mockNotificationPermission = 'granted'; + await openDropdown(); + await waitFor(() => { + expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); + }); + }); + + it('hides enable button when permission is denied', async () => { + mockNotificationPermission = 'denied'; + await openDropdown(); + await waitFor(() => { + expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); + }); + }); + + it('calls requestPermission when enable button clicked', async () => { + mockNotificationPermission = 'default'; + const user = await openDropdown(); + await user.click(await screen.findByText('Enable desktop notifications')); + expect(mockRequestPermission).toHaveBeenCalled(); + }); + + it('shows browser notification when SSE notification received and permission granted', async () => { + mockNotificationPermission = 'granted'; + const mockNotificationConstructor = vi.fn(); + vi.stubGlobal('Notification', { + get permission() { return 'granted'; }, + requestPermission: mockRequestPermission, + }); + (globalThis as unknown as { Notification: unknown }).Notification = class { + constructor(title: string, options?: NotificationOptions) { + mockNotificationConstructor(title, options); + } + static get permission() { return 'granted'; } + static requestPermission = mockRequestPermission; + }; + + const instance = await setupSSE(); + instance.simulateMessage({ notification_id: 'n1', subject: 'Test Title', body: 'Test Body' }); + + await waitFor(() => { + expect(mockNotificationConstructor).toHaveBeenCalledWith('Test Title', expect.objectContaining({ body: 'Test Body' })); + }); + }); + }); }); diff --git a/frontend/src/components/editor/LanguageSelect.svelte b/frontend/src/components/editor/LanguageSelect.svelte index d9ed201..a90f256 100644 --- a/frontend/src/components/editor/LanguageSelect.svelte +++ b/frontend/src/components/editor/LanguageSelect.svelte @@ -14,13 +14,83 @@ let showOptions = $state(false); let hoveredLang = $state(null); + let focusedLangIndex = $state(-1); + let focusedVersionIndex = $state(-1); const available = $derived(Object.keys(runtimes).length > 0); + const langKeys = $derived(Object.keys(runtimes)); + + function closeMenu() { + showOptions = false; + hoveredLang = null; + focusedLangIndex = -1; + focusedVersionIndex = -1; + } + + function selectVersion(l: string, v: string) { + onselect(l, v); + closeMenu(); + } + + function handleTriggerKeydown(e: KeyboardEvent) { + if (e.key === 'Escape' && showOptions) { + e.preventDefault(); + closeMenu(); + } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !showOptions && available) { + e.preventDefault(); + showOptions = true; + focusedLangIndex = 0; + } + } + + function handleMenuKeydown(e: KeyboardEvent) { + const inSubmenu = hoveredLang !== null && focusedVersionIndex >= 0; + const currentVersions = hoveredLang ? runtimes[hoveredLang]?.versions ?? [] : []; + + if (e.key === 'Escape') { + e.preventDefault(); + if (inSubmenu) { + hoveredLang = null; + focusedVersionIndex = -1; + } else { + closeMenu(); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (inSubmenu) { + focusedVersionIndex = Math.min(focusedVersionIndex + 1, currentVersions.length - 1); + } else { + focusedLangIndex = Math.min(focusedLangIndex + 1, langKeys.length - 1); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (inSubmenu) { + focusedVersionIndex = Math.max(focusedVersionIndex - 1, 0); + } else { + focusedLangIndex = Math.max(focusedLangIndex - 1, 0); + } + } else if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (inSubmenu && (e.key === 'Enter' || e.key === ' ')) { + selectVersion(hoveredLang!, currentVersions[focusedVersionIndex]); + } else if (!inSubmenu && focusedLangIndex >= 0) { + hoveredLang = langKeys[focusedLangIndex]; + focusedVersionIndex = 0; + } + } else if (e.key === 'ArrowLeft' && inSubmenu) { + e.preventDefault(); + hoveredLang = null; + focusedVersionIndex = -1; + } + }
-
+ {#if hoveredLang === l && info.versions.length > 0} +{:else if variant === 'with-text'} +
+ {#each actions as action} + {@const IconComponent = action.icon} + + {/each} +
+{:else} +
+ {#each actions as action} + {@const IconComponent = action.icon} + + {/each} +
+{/if} diff --git a/frontend/src/components/admin/AutoRefreshControl.svelte b/frontend/src/components/admin/AutoRefreshControl.svelte new file mode 100644 index 0000000..3c13dad --- /dev/null +++ b/frontend/src/components/admin/AutoRefreshControl.svelte @@ -0,0 +1,88 @@ + + +
+
+ + + {#if enabled} +
+ + +
+ {/if} + + +
+
diff --git a/frontend/src/components/admin/FilterPanel.svelte b/frontend/src/components/admin/FilterPanel.svelte new file mode 100644 index 0000000..6a39501 --- /dev/null +++ b/frontend/src/components/admin/FilterPanel.svelte @@ -0,0 +1,82 @@ + + +{#if showToggleButton} + +{/if} + +{#if open} +
+
+
+

+ {title} +

+
+ {#if onClear} + + {/if} + {#if onApply} + + {/if} +
+
+ + {@render children()} +
+
+{/if} diff --git a/frontend/src/components/admin/StatsCard.svelte b/frontend/src/components/admin/StatsCard.svelte new file mode 100644 index 0000000..5560e08 --- /dev/null +++ b/frontend/src/components/admin/StatsCard.svelte @@ -0,0 +1,44 @@ + + +
+
+
+
+

+ {label} +

+

+ {value} +

+ {#if sublabel} +

{sublabel}

+ {/if} +
+ {#if IconComponent} + + {/if} +
+
+
diff --git a/frontend/src/components/admin/StatusBadge.svelte b/frontend/src/components/admin/StatusBadge.svelte new file mode 100644 index 0000000..e64676d --- /dev/null +++ b/frontend/src/components/admin/StatusBadge.svelte @@ -0,0 +1,28 @@ + + + + {status} + {#if suffix} + {suffix} + {/if} + diff --git a/frontend/src/components/admin/events/EventDetailsModal.svelte b/frontend/src/components/admin/events/EventDetailsModal.svelte new file mode 100644 index 0000000..f9eda7a --- /dev/null +++ b/frontend/src/components/admin/events/EventDetailsModal.svelte @@ -0,0 +1,103 @@ + + + + {#if event} +
+
+

Basic Information

+ + + + + + + + + + + + + + + + + + + + + + + +
Event ID{event.event.event_id}
Event Type +
+ + + + + {event.event.event_type} + +
+
Timestamp{formatTimestamp(event.event.timestamp)}
Correlation ID{event.event.correlation_id}
Aggregate ID{event.event.aggregate_id || '-'}
+
+ +
+

Metadata

+
{JSON.stringify(event.event.metadata, null, 2)}
+
+ +
+

Payload

+
{JSON.stringify(event.event.payload, null, 2)}
+
+ + {#if event.related_events && event.related_events.length > 0} +
+

Related Events

+
+ {#each event.related_events as related} + + {/each} +
+
+ {/if} +
+ {/if} + + {#snippet footer()} + + + {/snippet} +
diff --git a/frontend/src/components/admin/events/EventFilters.svelte b/frontend/src/components/admin/events/EventFilters.svelte new file mode 100644 index 0000000..100a5d6 --- /dev/null +++ b/frontend/src/components/admin/events/EventFilters.svelte @@ -0,0 +1,157 @@ + + +
+
+
+

+ Filter Events +

+
+ + +
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/frontend/src/components/admin/events/EventStatsCards.svelte b/frontend/src/components/admin/events/EventStatsCards.svelte new file mode 100644 index 0000000..963132b --- /dev/null +++ b/frontend/src/components/admin/events/EventStatsCards.svelte @@ -0,0 +1,46 @@ + + +{#if stats} +
+
+
Events (Last 24h)
+
+ {stats?.total_events?.toLocaleString() || '0'} +
+
+ of {totalEvents?.toLocaleString() || '0'} total +
+
+ +
+
Error Rate (24h)
+
+ {stats?.error_rate || 0}% +
+
+ +
+
Avg Execution Time (24h)
+
+ {stats?.avg_processing_time ? stats.avg_processing_time.toFixed(2) : '0'}s +
+
+ +
+
Active Users (24h)
+
+ {stats?.top_users?.length || 0} +
+
with events
+
+
+{/if} diff --git a/frontend/src/components/admin/events/EventsTable.svelte b/frontend/src/components/admin/events/EventsTable.svelte new file mode 100644 index 0000000..c66e5aa --- /dev/null +++ b/frontend/src/components/admin/events/EventsTable.svelte @@ -0,0 +1,207 @@ + + + + + + +
+ {#each events as event} +
onViewDetails(event.event_id)} + onkeydown={(e) => e.key === 'Enter' && onViewDetails(event.event_id)} + tabindex="0" + role="button" + aria-label="View event details" + > +
+
+
+
+ + + + +
+
+ {formatTimestamp(event.timestamp)} +
+
+
+
+ + + +
+
+
+
+ User: + {#if event.metadata?.user_id} + + {:else} + - + {/if} +
+
+ Service: + + {event.metadata?.service_name || '-'} + +
+
+ Correlation: + + {event.correlation_id} + +
+
+
+ {/each} +
+ +{#if events.length === 0} +
+ No events found +
+{/if} diff --git a/frontend/src/components/admin/events/ReplayPreviewModal.svelte b/frontend/src/components/admin/events/ReplayPreviewModal.svelte new file mode 100644 index 0000000..27b0861 --- /dev/null +++ b/frontend/src/components/admin/events/ReplayPreviewModal.svelte @@ -0,0 +1,89 @@ + + + + {#if preview} +

+ Review the events that will be replayed +

+ +
+
+
+ + {preview.total_events} event{preview.total_events !== 1 ? 's' : ''} will be replayed + + Dry Run +
+
+
+ + {#if preview.events_preview && preview.events_preview.length > 0} +
+

Events to Replay:

+ {#each preview.events_preview as event} +
+
+
+
{event.event_id}
+
{event.event_type}
+ {#if event.aggregate_id} +
Aggregate: {event.aggregate_id}
+ {/if} +
+
{formatTimestamp(event.timestamp)}
+
+
+ {/each} +
+ {/if} + +
+
+ +
+

Warning

+
+ Replaying events will re-process them through the system. This may trigger new executions + and create duplicate results if the events have already been processed. +
+
+
+
+ {/if} + + {#snippet footer()} + + + {/snippet} +
diff --git a/frontend/src/components/admin/events/ReplayProgressBanner.svelte b/frontend/src/components/admin/events/ReplayProgressBanner.svelte new file mode 100644 index 0000000..b25f41d --- /dev/null +++ b/frontend/src/components/admin/events/ReplayProgressBanner.svelte @@ -0,0 +1,113 @@ + + +{#if session} +
+ + + +
+

Replay in Progress

+ + {session.status} + +
+
+
+ Progress: {session.replayed_events} / {session.total_events} events + {session.progress_percentage}% +
+
+
+
+
+ + {#if session.failed_events > 0} +
+
+ Failed: {session.failed_events} events +
+ {#if session.error_message} +
+

+ Error: {session.error_message} +

+
+ {/if} + {#if session.failed_event_errors && session.failed_event_errors.length > 0} +
+ {#each session.failed_event_errors as error} +
+
{error.event_id}
+
{error.error}
+
+ {/each} +
+ {/if} +
+ {/if} + + {#if session.execution_results && session.execution_results.length > 0} +
+

Execution Results:

+
+ {#each session.execution_results as result} +
+
+
+ {result.execution_id} +
+ + {result.status} + + {#if result.execution_time} + + {result.execution_time.toFixed(2)}s + + {/if} +
+
+ {#if result.output || result.errors} +
+ {#if result.output} +
+ Output: {result.output} +
+ {/if} + {#if result.errors} +
+ Error: {result.errors} +
+ {/if} +
+ {/if} +
+
+ {/each} +
+
+ {/if} +
+{/if} diff --git a/frontend/src/components/admin/events/UserOverviewModal.svelte b/frontend/src/components/admin/events/UserOverviewModal.svelte new file mode 100644 index 0000000..4d45fe8 --- /dev/null +++ b/frontend/src/components/admin/events/UserOverviewModal.svelte @@ -0,0 +1,101 @@ + + + + {#if loading} +
+ +
+ {:else if overview} +
+ +
+

Profile

+
+
User ID: {overview.user.user_id}
+
Username: {overview.user.username}
+
Email: {overview.user.email}
+
Role: {overview.user.role}
+
Active: {overview.user.is_active ? 'Yes' : 'No'}
+
Superuser: {overview.user.is_superuser ? 'Yes' : 'No'}
+
+ {#if overview.rate_limit_summary} +
+

Rate Limits

+
+
Bypass: {overview.rate_limit_summary.bypass_rate_limit ? 'Yes' : 'No'}
+
Global Multiplier: {overview.rate_limit_summary.global_multiplier ?? 1.0}
+
Custom Rules: {overview.rate_limit_summary.has_custom_limits ? 'Yes' : 'No'}
+
+
+ {/if} +
+ + +
+

Execution Stats (last 24h)

+
+
+
Succeeded
+
{overview.derived_counts.succeeded}
+
+
+
Failed
+
{overview.derived_counts.failed}
+
+
+
Timeout
+
{overview.derived_counts.timeout}
+
+
+
Cancelled
+
{overview.derived_counts.cancelled}
+
+
+
+ Terminal Total: {overview.derived_counts.terminal_total} +
+
+ Total Events: {overview.stats.total_events} +
+
+
+ + {#if overview.recent_events && overview.recent_events.length > 0} +
+

Recent Execution Events

+
+ {#each overview.recent_events as ev} +
+
+ {getEventTypeLabel(ev.event_type) || ev.event_type} + {ev.aggregate_id || '-'} +
+
{formatTimestamp(ev.timestamp)}
+
+ {/each} +
+
+ {/if} + {:else} +
No data available
+ {/if} + + {#snippet footer()} + Open User Management + {/snippet} +
diff --git a/frontend/src/components/admin/events/index.ts b/frontend/src/components/admin/events/index.ts new file mode 100644 index 0000000..2502365 --- /dev/null +++ b/frontend/src/components/admin/events/index.ts @@ -0,0 +1,7 @@ +export { default as EventStatsCards } from './EventStatsCards.svelte'; +export { default as EventFilters } from './EventFilters.svelte'; +export { default as EventsTable } from './EventsTable.svelte'; +export { default as EventDetailsModal } from './EventDetailsModal.svelte'; +export { default as ReplayPreviewModal } from './ReplayPreviewModal.svelte'; +export { default as ReplayProgressBanner } from './ReplayProgressBanner.svelte'; +export { default as UserOverviewModal } from './UserOverviewModal.svelte'; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts new file mode 100644 index 0000000..5f538ae --- /dev/null +++ b/frontend/src/components/admin/index.ts @@ -0,0 +1,6 @@ +// Shared admin components +export { default as AutoRefreshControl } from './AutoRefreshControl.svelte'; +export { default as StatsCard } from './StatsCard.svelte'; +export { default as StatusBadge } from './StatusBadge.svelte'; +export { default as FilterPanel } from './FilterPanel.svelte'; +export { default as ActionButtons } from './ActionButtons.svelte'; diff --git a/frontend/src/components/admin/sagas/SagaDetailsModal.svelte b/frontend/src/components/admin/sagas/SagaDetailsModal.svelte new file mode 100644 index 0000000..e39a435 --- /dev/null +++ b/frontend/src/components/admin/sagas/SagaDetailsModal.svelte @@ -0,0 +1,195 @@ + + + + {#snippet children()} + {#if saga} + {@const stateInfo = getSagaStateInfo(saga.state)} +
+
+

Basic Information

+
+
+
Saga ID
+
{saga.saga_id}
+
+
+
Saga Name
+
{saga.saga_name}
+
+
+
Execution ID
+
+ +
+
+
+
State
+
{stateInfo.label}
+
+
+
Retry Count
+
{saga.retry_count}
+
+
+
+
+

Timing Information

+
+
+
Created At
+
{formatTimestamp(saga.created_at)}
+
+
+
Updated At
+
{formatTimestamp(saga.updated_at)}
+
+
+
Completed At
+
{formatTimestamp(saga.completed_at)}
+
+
+
Duration
+
{formatDurationBetween(saga.created_at, saga.completed_at || saga.updated_at)}
+
+
+
+
+ +
+

Execution Steps

+ + {#if saga.saga_name === 'execution_saga'} +
+
+ {#each EXECUTION_SAGA_STEPS as step, index} + {@const isCompleted = saga.completed_steps.includes(step.name)} + {@const isCompensated = step.compensation && saga.compensated_steps.includes(step.compensation)} + {@const isCurrent = saga.current_step === step.name} + {@const isFailed = saga.state === 'failed' && isCurrent} + +
+
+
+ {#if index > 0} + {@const prevCompleted = saga.completed_steps.includes(EXECUTION_SAGA_STEPS[index - 1].name)} +
+ {/if} +
+
+
+ {#if isCompleted} + {:else if isCompensated} + {:else if isCurrent}
+ {:else if isFailed} + {:else}{index + 1}{/if} +
+
+
+ {#if index < EXECUTION_SAGA_STEPS.length - 1} + {@const nextCompleted = saga.completed_steps.includes(EXECUTION_SAGA_STEPS[index + 1].name)} +
+ {/if} +
+
+
+ {step.label} + {#if step.compensation && isCompensated} +
(compensated)
+ {/if} +
+
+ {/each} +
+
+ {/if} + + {#if saga.current_step} +
+ Current Step: {saga.current_step} +
+ {/if} + +
+
+

Completed ({saga.completed_steps.length})

+ {#if saga.completed_steps.length > 0} +
    + {#each saga.completed_steps as step} +
  • + {step} +
  • + {/each} +
+ {:else} +

No completed steps

+ {/if} +
+
+

Compensated ({saga.compensated_steps.length})

+ {#if saga.compensated_steps.length > 0} +
    + {#each saga.compensated_steps as step} +
  • + {step} +
  • + {/each} +
+ {:else} +

No compensated steps

+ {/if} +
+
+
+ + {#if saga.error_message} +
+

Error Information

+
+
{saga.error_message}
+
+
+ {/if} + + {#if saga.context_data && Object.keys(saga.context_data).length > 0} +
+

Context Data

+
+
{JSON.stringify(saga.context_data, null, 2)}
+
+
+ {/if} + {/if} + {/snippet} +
diff --git a/frontend/src/components/admin/sagas/SagaFilters.svelte b/frontend/src/components/admin/sagas/SagaFilters.svelte new file mode 100644 index 0000000..ad2b23a --- /dev/null +++ b/frontend/src/components/admin/sagas/SagaFilters.svelte @@ -0,0 +1,60 @@ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ Actions + +
+
diff --git a/frontend/src/components/admin/sagas/SagaStatsCards.svelte b/frontend/src/components/admin/sagas/SagaStatsCards.svelte new file mode 100644 index 0000000..a48df61 --- /dev/null +++ b/frontend/src/components/admin/sagas/SagaStatsCards.svelte @@ -0,0 +1,36 @@ + + +
+ {#each Object.entries(SAGA_STATES) as [state, info]} + {@const count = getCount(state)} + {@const IconComponent = info.icon} +
+
+
+
+

+ {info.label} +

+

+ {count} +

+
+ +
+
+
+ {/each} +
diff --git a/frontend/src/components/admin/sagas/SagasTable.svelte b/frontend/src/components/admin/sagas/SagasTable.svelte new file mode 100644 index 0000000..c418d06 --- /dev/null +++ b/frontend/src/components/admin/sagas/SagasTable.svelte @@ -0,0 +1,144 @@ + + +
+ {#if loading && sagas.length === 0} +
+ +

Loading sagas...

+
+ {:else if sagas.length === 0} +
No sagas found
+ {:else} + +
+ {#each sagas as saga} + {@const stateInfo = getSagaStateInfo(saga.state)} + {@const progress = getSagaProgressPercentage(saga.completed_steps, saga.saga_name)} +
+
+
+
{saga.saga_name}
+
ID: {saga.saga_id.slice(0, 12)}...
+
+ + {stateInfo.label} + {#if saga.retry_count > 0}({saga.retry_count}){/if} + +
+
+
+ Started: +
{formatTimestamp(saga.created_at)}
+
+
+ Duration: +
{formatDurationBetween(saga.created_at, saga.completed_at || saga.updated_at)}
+
+
+
+
+ Progress: {saga.completed_steps.length} steps + {Math.round(progress)}% +
+
+
+
+
+
+ + +
+
+ {/each} +
+ + + + {/if} +
diff --git a/frontend/src/components/admin/sagas/index.ts b/frontend/src/components/admin/sagas/index.ts new file mode 100644 index 0000000..cc1feea --- /dev/null +++ b/frontend/src/components/admin/sagas/index.ts @@ -0,0 +1,4 @@ +export { default as SagaStatsCards } from './SagaStatsCards.svelte'; +export { default as SagaFilters } from './SagaFilters.svelte'; +export { default as SagasTable } from './SagasTable.svelte'; +export { default as SagaDetailsModal } from './SagaDetailsModal.svelte'; diff --git a/frontend/src/components/admin/users/DeleteUserModal.svelte b/frontend/src/components/admin/users/DeleteUserModal.svelte new file mode 100644 index 0000000..63d80de --- /dev/null +++ b/frontend/src/components/admin/users/DeleteUserModal.svelte @@ -0,0 +1,69 @@ + + + + {#snippet children()} + {#if user} +

+ Are you sure you want to delete user {user.username}? +

+
+ +
+ {#if cascadeDelete} +
+ Warning: This will permanently delete all data associated with this user. +
+ {/if} +
+ + +
+ {/if} + {/snippet} +
diff --git a/frontend/src/components/admin/users/RateLimitsModal.svelte b/frontend/src/components/admin/users/RateLimitsModal.svelte new file mode 100644 index 0000000..3b1b2fb --- /dev/null +++ b/frontend/src/components/admin/users/RateLimitsModal.svelte @@ -0,0 +1,261 @@ + + + + {#snippet children()} + {#if loading} +
+ {:else if config} +
+ +
+
+

Quick Settings

+ +
+
+
+ + +

+ Multiplies all limits (1.0 = default, 2.0 = double) +

+
+
+ + +
+
+
+ + +
+
+

Endpoint Rate Limits

+ +
+ + +
+
Default Global Rules
+
+ {#each defaultRulesWithEffective as rule} +
+
+
+ Endpoint +

{rule.endpoint_pattern}

+
+
+ Limit +

+ {#if config.global_multiplier !== 1.0} + {rule.requests} + {rule.effective_requests} + {:else} + {rule.requests} + {/if} + req / {rule.window_seconds}s +

+
+
+ Group + {rule.group} +
+
+ Algorithm +

{rule.algorithm}

+
+
+
+ {/each} +
+
+ + + {#if config.rules && config.rules.length > 0} +
+
User-Specific Overrides
+
+ {#each config.rules as rule, index} +
+
+
+ handleEndpointChange(rule)} + placeholder="Endpoint pattern" + class="input input-sm w-full" + disabled={config.bypass_rate_limit} + /> +
+
+ + / + + s + {#if config.global_multiplier !== 1.0} + + (→ {Math.floor(rule.requests * config.global_multiplier)}/{rule.window_seconds}s) + + {/if} +
+ {rule.group} + + + +
+
+ {/each} +
+
+ {/if} +
+ + + {#if usage && Object.keys(usage).length > 0} +
+
+

Current Usage

+ +
+
+ {#each Object.entries(usage) as [endpoint, usageData]} +
+ {endpoint} + + {usageData.count || usageData.tokens_remaining || 0} + +
+ {/each} +
+
+ {/if} +
+
+ + +
+ {/if} + {/snippet} +
diff --git a/frontend/src/components/admin/users/UserFilters.svelte b/frontend/src/components/admin/users/UserFilters.svelte new file mode 100644 index 0000000..b5077ab --- /dev/null +++ b/frontend/src/components/admin/users/UserFilters.svelte @@ -0,0 +1,112 @@ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + {#if showAdvancedFilters} +
+

Rate Limit Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} +
+
diff --git a/frontend/src/components/admin/users/UserFormModal.svelte b/frontend/src/components/admin/users/UserFormModal.svelte new file mode 100644 index 0000000..01221d8 --- /dev/null +++ b/frontend/src/components/admin/users/UserFormModal.svelte @@ -0,0 +1,122 @@ + + + + {#snippet children()} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ {/snippet} +
diff --git a/frontend/src/components/admin/users/UsersTable.svelte b/frontend/src/components/admin/users/UsersTable.svelte new file mode 100644 index 0000000..8136f47 --- /dev/null +++ b/frontend/src/components/admin/users/UsersTable.svelte @@ -0,0 +1,122 @@ + + +{#if loading} +
Loading users...
+{:else if users.length === 0} +
No users found matching filters
+{:else} + +
+ {#each users as user} +
+
+
+
{user.username}
+
{user.email || 'No email'}
+
+
+ {user.role} + + {user.is_active ? 'Active' : 'Inactive'} + +
+
+
+ Created: {formatTimestamp(user.created_at)} +
+
+ + + +
+
+ {/each} +
+ + + +{/if} diff --git a/frontend/src/components/admin/users/index.ts b/frontend/src/components/admin/users/index.ts new file mode 100644 index 0000000..1928ed2 --- /dev/null +++ b/frontend/src/components/admin/users/index.ts @@ -0,0 +1,5 @@ +export { default as UserFilters } from './UserFilters.svelte'; +export { default as UsersTable } from './UsersTable.svelte'; +export { default as UserFormModal } from './UserFormModal.svelte'; +export { default as DeleteUserModal } from './DeleteUserModal.svelte'; +export { default as RateLimitsModal } from './RateLimitsModal.svelte'; diff --git a/frontend/src/lib/admin/__tests__/constants.test.ts b/frontend/src/lib/admin/__tests__/constants.test.ts new file mode 100644 index 0000000..0ae884f --- /dev/null +++ b/frontend/src/lib/admin/__tests__/constants.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { + STATUS_COLORS, + STATS_BG_COLORS, + STATS_TEXT_COLORS, + ROLE_COLORS, + ACTIVE_STATUS_COLORS +} from '../constants'; + +describe('admin constants', () => { + describe('STATUS_COLORS', () => { + it('has all expected status types', () => { + expect(STATUS_COLORS.success).toBe('badge-success'); + expect(STATUS_COLORS.error).toBe('badge-danger'); + expect(STATUS_COLORS.warning).toBe('badge-warning'); + expect(STATUS_COLORS.info).toBe('badge-info'); + expect(STATUS_COLORS.neutral).toBe('badge-neutral'); + }); + }); + + describe('STATS_BG_COLORS', () => { + it('has all expected background colors', () => { + expect(STATS_BG_COLORS.green).toContain('bg-green'); + expect(STATS_BG_COLORS.red).toContain('bg-red'); + expect(STATS_BG_COLORS.yellow).toContain('bg-yellow'); + expect(STATS_BG_COLORS.blue).toContain('bg-blue'); + expect(STATS_BG_COLORS.purple).toContain('bg-purple'); + expect(STATS_BG_COLORS.orange).toContain('bg-orange'); + expect(STATS_BG_COLORS.neutral).toContain('bg-neutral'); + }); + + it('includes dark mode variants', () => { + expect(STATS_BG_COLORS.green).toContain('dark:bg-green'); + }); + }); + + describe('STATS_TEXT_COLORS', () => { + it('has all expected text colors', () => { + expect(STATS_TEXT_COLORS.green).toContain('text-green'); + expect(STATS_TEXT_COLORS.red).toContain('text-red'); + expect(STATS_TEXT_COLORS.yellow).toContain('text-yellow'); + expect(STATS_TEXT_COLORS.blue).toContain('text-blue'); + }); + + it('includes dark mode variants', () => { + expect(STATS_TEXT_COLORS.green).toContain('dark:text-green'); + }); + }); + + describe('ROLE_COLORS', () => { + it('has admin and user roles', () => { + expect(ROLE_COLORS.admin).toBe('badge-info'); + expect(ROLE_COLORS.user).toBe('badge-neutral'); + }); + + it('returns undefined for unknown role', () => { + expect(ROLE_COLORS['unknown']).toBeUndefined(); + }); + }); + + describe('ACTIVE_STATUS_COLORS', () => { + it('has active/inactive status colors', () => { + expect(ACTIVE_STATUS_COLORS.active).toBe('badge-success'); + expect(ACTIVE_STATUS_COLORS.inactive).toBe('badge-danger'); + expect(ACTIVE_STATUS_COLORS.disabled).toBe('badge-danger'); + }); + }); +}); diff --git a/frontend/src/lib/admin/autoRefresh.svelte.ts b/frontend/src/lib/admin/autoRefresh.svelte.ts new file mode 100644 index 0000000..4115d51 --- /dev/null +++ b/frontend/src/lib/admin/autoRefresh.svelte.ts @@ -0,0 +1,106 @@ +/** + * Auto-refresh state factory for admin pages + * Manages interval-based data reloading with configurable rates + */ + +import { onDestroy } from 'svelte'; + +export interface AutoRefreshState { + enabled: boolean; + rate: number; + readonly rateOptions: RefreshRateOption[]; + start: () => void; + stop: () => void; + restart: () => void; + cleanup: () => void; +} + +export interface RefreshRateOption { + value: number; + label: string; +} + +export interface AutoRefreshOptions { + initialEnabled?: boolean; + initialRate?: number; + rateOptions?: RefreshRateOption[]; + onRefresh: () => void | Promise; + autoCleanup?: boolean; +} + +const DEFAULT_RATE_OPTIONS: RefreshRateOption[] = [ + { value: 5, label: '5 seconds' }, + { value: 10, label: '10 seconds' }, + { value: 30, label: '30 seconds' }, + { value: 60, label: '1 minute' } +]; + +const DEFAULT_OPTIONS = { + initialEnabled: true, + initialRate: 5, + rateOptions: DEFAULT_RATE_OPTIONS, + autoCleanup: true +}; + +/** + * Creates reactive auto-refresh state with interval management + * @example + * const autoRefresh = createAutoRefresh({ + * onRefresh: loadData, + * initialRate: 10 + * }); + */ +export function createAutoRefresh(options: AutoRefreshOptions): AutoRefreshState { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + let enabled = $state(opts.initialEnabled); + let rate = $state(opts.initialRate); + let interval: ReturnType | null = null; + + function start(): void { + stop(); + if (enabled) { + interval = setInterval(opts.onRefresh, rate * 1000); + } + } + + function stop(): void { + if (interval) { + clearInterval(interval); + interval = null; + } + } + + function restart(): void { + start(); + } + + function cleanup(): void { + stop(); + } + + // Auto-cleanup on component destroy if enabled + if (opts.autoCleanup) { + onDestroy(cleanup); + } + + // Watch for changes to enabled/rate and restart + $effect(() => { + if (enabled || rate) { + start(); + } + return () => stop(); + }); + + return { + get enabled() { return enabled; }, + set enabled(v: boolean) { enabled = v; }, + get rate() { return rate; }, + set rate(v: number) { rate = v; }, + get rateOptions() { return opts.rateOptions; }, + start, + stop, + restart, + cleanup + }; +} diff --git a/frontend/src/lib/admin/constants.ts b/frontend/src/lib/admin/constants.ts new file mode 100644 index 0000000..ae15e73 --- /dev/null +++ b/frontend/src/lib/admin/constants.ts @@ -0,0 +1,51 @@ +/** + * Shared constants for admin pages + */ + +// Common badge/status color classes +export const STATUS_COLORS = { + success: 'badge-success', + error: 'badge-danger', + warning: 'badge-warning', + info: 'badge-info', + neutral: 'badge-neutral' +} as const; + +// Common background colors for stats cards +export const STATS_BG_COLORS = { + green: 'bg-green-50 dark:bg-green-900/20', + red: 'bg-red-50 dark:bg-red-900/20', + yellow: 'bg-yellow-50 dark:bg-yellow-900/20', + blue: 'bg-blue-50 dark:bg-blue-900/20', + purple: 'bg-purple-50 dark:bg-purple-900/20', + orange: 'bg-orange-50 dark:bg-orange-900/20', + neutral: 'bg-neutral-50 dark:bg-neutral-900/20' +} as const; + +// Common text colors +export const STATS_TEXT_COLORS = { + green: 'text-green-600 dark:text-green-400', + red: 'text-red-600 dark:text-red-400', + yellow: 'text-yellow-600 dark:text-yellow-400', + blue: 'text-blue-600 dark:text-blue-400', + purple: 'text-purple-600 dark:text-purple-400', + orange: 'text-orange-600 dark:text-orange-400', + neutral: 'text-neutral-600 dark:text-neutral-400' +} as const; + +// Role colors +export const ROLE_COLORS: Record = { + admin: 'badge-info', + user: 'badge-neutral' +}; + +// Active/inactive status colors +export const ACTIVE_STATUS_COLORS = { + active: 'badge-success', + inactive: 'badge-danger', + disabled: 'badge-danger' +} as const; + +export type StatusColor = keyof typeof STATUS_COLORS; +export type StatsBgColor = keyof typeof STATS_BG_COLORS; +export type StatsTextColor = keyof typeof STATS_TEXT_COLORS; diff --git a/frontend/src/lib/admin/events/__tests__/eventTypes.test.ts b/frontend/src/lib/admin/events/__tests__/eventTypes.test.ts new file mode 100644 index 0000000..daad27b --- /dev/null +++ b/frontend/src/lib/admin/events/__tests__/eventTypes.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import { + EVENT_TYPES, + getEventTypeColor, + getEventTypeLabel, + createDefaultEventFilters, + hasActiveFilters, + getActiveFilterCount, + getActiveFilterSummary, + type EventFilters +} from '../eventTypes'; + +describe('eventTypes', () => { + describe('EVENT_TYPES', () => { + it('contains execution events', () => { + expect(EVENT_TYPES).toContain('execution.requested'); + expect(EVENT_TYPES).toContain('execution.started'); + expect(EVENT_TYPES).toContain('execution.completed'); + expect(EVENT_TYPES).toContain('execution.failed'); + expect(EVENT_TYPES).toContain('execution.timeout'); + }); + + it('contains pod events', () => { + expect(EVENT_TYPES).toContain('pod.created'); + expect(EVENT_TYPES).toContain('pod.running'); + expect(EVENT_TYPES).toContain('pod.succeeded'); + expect(EVENT_TYPES).toContain('pod.failed'); + expect(EVENT_TYPES).toContain('pod.terminated'); + }); + }); + + describe('getEventTypeColor', () => { + it('returns green for completed/succeeded events', () => { + expect(getEventTypeColor('execution.completed')).toContain('text-green'); + expect(getEventTypeColor('pod.succeeded')).toContain('text-green'); + }); + + it('returns red for failed/timeout events', () => { + expect(getEventTypeColor('execution.failed')).toContain('text-red'); + expect(getEventTypeColor('execution.timeout')).toContain('text-red'); + expect(getEventTypeColor('pod.failed')).toContain('text-red'); + }); + + it('returns blue for started/running events', () => { + expect(getEventTypeColor('execution.started')).toContain('text-blue'); + expect(getEventTypeColor('pod.running')).toContain('text-blue'); + }); + + it('returns purple for requested events', () => { + expect(getEventTypeColor('execution.requested')).toContain('text-purple'); + }); + + it('returns indigo for created events', () => { + expect(getEventTypeColor('pod.created')).toContain('text-indigo'); + }); + + it('returns orange for terminated events', () => { + expect(getEventTypeColor('pod.terminated')).toContain('text-orange'); + }); + + it('returns neutral for unknown events', () => { + expect(getEventTypeColor('unknown.event')).toContain('text-neutral'); + }); + + it('includes dark mode variants', () => { + expect(getEventTypeColor('execution.completed')).toContain('dark:'); + }); + }); + + describe('getEventTypeLabel', () => { + it('returns empty string for execution.requested', () => { + expect(getEventTypeLabel('execution.requested')).toBe(''); + }); + + it('returns formatted label for two-part event types', () => { + expect(getEventTypeLabel('execution.completed')).toBe('execution.completed'); + expect(getEventTypeLabel('pod.running')).toBe('pod.running'); + }); + + it('returns original string for other formats', () => { + expect(getEventTypeLabel('single')).toBe('single'); + expect(getEventTypeLabel('a.b.c')).toBe('a.b.c'); + }); + }); + + describe('createDefaultEventFilters', () => { + it('returns object with all empty values', () => { + const filters = createDefaultEventFilters(); + expect(filters.event_types).toEqual([]); + expect(filters.aggregate_id).toBe(''); + expect(filters.correlation_id).toBe(''); + expect(filters.user_id).toBe(''); + expect(filters.service_name).toBe(''); + expect(filters.search_text).toBe(''); + expect(filters.start_time).toBe(''); + expect(filters.end_time).toBe(''); + }); + + it('returns new object each time', () => { + const a = createDefaultEventFilters(); + const b = createDefaultEventFilters(); + expect(a).not.toBe(b); + }); + }); + + describe('hasActiveFilters', () => { + it('returns false for empty filters', () => { + expect(hasActiveFilters(createDefaultEventFilters())).toBe(false); + }); + + it('returns true when event_types has values', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), event_types: ['execution.completed'] }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when search_text has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), search_text: 'test' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when correlation_id has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), correlation_id: 'abc' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when aggregate_id has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), aggregate_id: 'exec-1' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when user_id has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), user_id: 'user-1' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when service_name has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), service_name: 'execution-service' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when start_time has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), start_time: '2024-01-01' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + + it('returns true when end_time has value', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), end_time: '2024-01-02' }; + expect(hasActiveFilters(filters)).toBe(true); + }); + }); + + describe('getActiveFilterCount', () => { + it('returns 0 for empty filters', () => { + expect(getActiveFilterCount(createDefaultEventFilters())).toBe(0); + }); + + it('counts each active filter', () => { + const filters: EventFilters = { + event_types: ['execution.completed'], + search_text: 'test', + correlation_id: 'abc', + aggregate_id: '', + user_id: '', + service_name: '', + start_time: '', + end_time: '' + }; + expect(getActiveFilterCount(filters)).toBe(3); + }); + + it('counts all filters when all active', () => { + const filters: EventFilters = { + event_types: ['execution.completed'], + search_text: 'test', + correlation_id: 'abc', + aggregate_id: 'exec-1', + user_id: 'user-1', + service_name: 'svc', + start_time: '2024-01-01', + end_time: '2024-01-02' + }; + expect(getActiveFilterCount(filters)).toBe(8); + }); + }); + + describe('getActiveFilterSummary', () => { + it('returns empty array for empty filters', () => { + expect(getActiveFilterSummary(createDefaultEventFilters())).toEqual([]); + }); + + it('includes event types with count', () => { + const filters: EventFilters = { + ...createDefaultEventFilters(), + event_types: ['execution.completed', 'pod.running'] + }; + expect(getActiveFilterSummary(filters)).toContain('2 event types'); + }); + + it('uses singular for single event type', () => { + const filters: EventFilters = { + ...createDefaultEventFilters(), + event_types: ['execution.completed'] + }; + expect(getActiveFilterSummary(filters)).toContain('1 event type'); + }); + + it('includes search', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), search_text: 'test' }; + expect(getActiveFilterSummary(filters)).toContain('search'); + }); + + it('includes correlation', () => { + const filters: EventFilters = { ...createDefaultEventFilters(), correlation_id: 'abc' }; + expect(getActiveFilterSummary(filters)).toContain('correlation'); + }); + + it('includes time range for start or end time', () => { + const filters1: EventFilters = { ...createDefaultEventFilters(), start_time: '2024-01-01' }; + expect(getActiveFilterSummary(filters1)).toContain('time range'); + + const filters2: EventFilters = { ...createDefaultEventFilters(), end_time: '2024-01-02' }; + expect(getActiveFilterSummary(filters2)).toContain('time range'); + }); + + it('includes all active filter labels', () => { + const filters: EventFilters = { + event_types: ['execution.completed'], + search_text: 'test', + correlation_id: 'abc', + aggregate_id: 'exec-1', + user_id: 'user-1', + service_name: 'svc', + start_time: '2024-01-01', + end_time: '' + }; + const summary = getActiveFilterSummary(filters); + expect(summary).toContain('1 event type'); + expect(summary).toContain('search'); + expect(summary).toContain('correlation'); + expect(summary).toContain('aggregate'); + expect(summary).toContain('user'); + expect(summary).toContain('service'); + expect(summary).toContain('time range'); + }); + }); +}); diff --git a/frontend/src/lib/admin/events/eventTypes.ts b/frontend/src/lib/admin/events/eventTypes.ts new file mode 100644 index 0000000..eb8e1f8 --- /dev/null +++ b/frontend/src/lib/admin/events/eventTypes.ts @@ -0,0 +1,122 @@ +/** + * Event type configurations and utilities + */ + +// Available event types for filtering +export const EVENT_TYPES = [ + 'execution.requested', + 'execution.started', + 'execution.completed', + 'execution.failed', + 'execution.timeout', + 'pod.created', + 'pod.running', + 'pod.succeeded', + 'pod.failed', + 'pod.terminated' +] as const; + +export type EventType = typeof EVENT_TYPES[number]; + +// Event type color mapping +export function getEventTypeColor(eventType: string): string { + if (eventType.includes('.completed') || eventType.includes('.succeeded')) { + return 'text-green-600 dark:text-green-400'; + } + if (eventType.includes('.failed') || eventType.includes('.timeout')) { + return 'text-red-600 dark:text-red-400'; + } + if (eventType.includes('.started') || eventType.includes('.running')) { + return 'text-blue-600 dark:text-blue-400'; + } + if (eventType.includes('.requested')) { + return 'text-purple-600 dark:text-purple-400'; + } + if (eventType.includes('.created')) { + return 'text-indigo-600 dark:text-indigo-400'; + } + if (eventType.includes('.terminated')) { + return 'text-orange-600 dark:text-orange-400'; + } + return 'text-neutral-600 dark:text-neutral-400'; +} + +// Get display label for event type +export function getEventTypeLabel(eventType: string): string { + // For execution.requested, show icon only (with tooltip) + if (eventType === 'execution.requested') { + return ''; + } + + // For all other events, show full name + const parts = eventType.split('.'); + if (parts.length === 2) { + return `${parts[0]}.${parts[1]}`; + } + return eventType; +} + +// Default filter state for events +export interface EventFilters { + event_types: string[]; + aggregate_id: string; + correlation_id: string; + user_id: string; + service_name: string; + search_text: string; + start_time: string; + end_time: string; +} + +export function createDefaultEventFilters(): EventFilters { + return { + event_types: [], + aggregate_id: '', + correlation_id: '', + user_id: '', + service_name: '', + search_text: '', + start_time: '', + end_time: '' + }; +} + +export function hasActiveFilters(filters: EventFilters): boolean { + return ( + filters.event_types.length > 0 || + !!filters.search_text || + !!filters.correlation_id || + !!filters.aggregate_id || + !!filters.user_id || + !!filters.service_name || + !!filters.start_time || + !!filters.end_time + ); +} + +export function getActiveFilterCount(filters: EventFilters): number { + let count = 0; + if (filters.event_types.length > 0) count++; + if (filters.search_text) count++; + if (filters.correlation_id) count++; + if (filters.aggregate_id) count++; + if (filters.user_id) count++; + if (filters.service_name) count++; + if (filters.start_time) count++; + if (filters.end_time) count++; + return count; +} + +export function getActiveFilterSummary(filters: EventFilters): string[] { + const items: string[] = []; + if (filters.event_types.length > 0) { + items.push(`${filters.event_types.length} event type${filters.event_types.length > 1 ? 's' : ''}`); + } + if (filters.search_text) items.push('search'); + if (filters.correlation_id) items.push('correlation'); + if (filters.aggregate_id) items.push('aggregate'); + if (filters.user_id) items.push('user'); + if (filters.service_name) items.push('service'); + if (filters.start_time || filters.end_time) items.push('time range'); + return items; +} diff --git a/frontend/src/lib/admin/events/index.ts b/frontend/src/lib/admin/events/index.ts new file mode 100644 index 0000000..d9217ea --- /dev/null +++ b/frontend/src/lib/admin/events/index.ts @@ -0,0 +1 @@ +export * from './eventTypes'; diff --git a/frontend/src/lib/admin/index.ts b/frontend/src/lib/admin/index.ts new file mode 100644 index 0000000..2122224 --- /dev/null +++ b/frontend/src/lib/admin/index.ts @@ -0,0 +1,9 @@ +// Shared admin utilities +export * from './pagination.svelte'; +export * from './autoRefresh.svelte'; +export * from './constants'; + +// Domain-specific exports +export * from './sagas'; +export * from './users'; +export * from './events'; diff --git a/frontend/src/lib/admin/pagination.svelte.ts b/frontend/src/lib/admin/pagination.svelte.ts new file mode 100644 index 0000000..16f5f17 --- /dev/null +++ b/frontend/src/lib/admin/pagination.svelte.ts @@ -0,0 +1,76 @@ +/** + * Pagination state factory for admin pages + * Provides reactive pagination state with page change handlers + */ + +export interface PaginationState { + currentPage: number; + pageSize: number; + totalItems: number; + readonly totalPages: number; + readonly skip: number; + handlePageChange: (page: number, onLoad?: () => void) => void; + handlePageSizeChange: (size: number, onLoad?: () => void) => void; + reset: () => void; +} + +export interface PaginationOptions { + initialPage?: number; + initialPageSize?: number; + pageSizeOptions?: number[]; +} + +const DEFAULT_OPTIONS: Required = { + initialPage: 1, + initialPageSize: 10, + pageSizeOptions: [5, 10, 20, 50] +}; + +/** + * Creates reactive pagination state + * @example + * const pagination = createPaginationState({ initialPageSize: 20 }); + * // Use in component: + * // pagination.currentPage, pagination.totalPages, etc. + */ +export function createPaginationState(options: PaginationOptions = {}): PaginationState { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + let currentPage = $state(opts.initialPage); + let pageSize = $state(opts.initialPageSize); + let totalItems = $state(0); + + const totalPages = $derived(Math.ceil(totalItems / pageSize) || 1); + const skip = $derived((currentPage - 1) * pageSize); + + function handlePageChange(page: number, onLoad?: () => void): void { + currentPage = page; + onLoad?.(); + } + + function handlePageSizeChange(size: number, onLoad?: () => void): void { + pageSize = size; + currentPage = 1; + onLoad?.(); + } + + function reset(): void { + currentPage = opts.initialPage; + pageSize = opts.initialPageSize; + totalItems = 0; + } + + return { + get currentPage() { return currentPage; }, + set currentPage(v: number) { currentPage = v; }, + get pageSize() { return pageSize; }, + set pageSize(v: number) { pageSize = v; }, + get totalItems() { return totalItems; }, + set totalItems(v: number) { totalItems = v; }, + get totalPages() { return totalPages; }, + get skip() { return skip; }, + handlePageChange, + handlePageSizeChange, + reset + }; +} diff --git a/frontend/src/lib/admin/sagas/__tests__/sagaStates.test.ts b/frontend/src/lib/admin/sagas/__tests__/sagaStates.test.ts new file mode 100644 index 0000000..a5cf5f3 --- /dev/null +++ b/frontend/src/lib/admin/sagas/__tests__/sagaStates.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + SAGA_STATES, + getSagaStateInfo, + EXECUTION_SAGA_STEPS, + getSagaProgressPercentage +} from '../sagaStates'; + +describe('sagaStates', () => { + describe('SAGA_STATES', () => { + it('has all expected states', () => { + expect(SAGA_STATES.created).toBeDefined(); + expect(SAGA_STATES.running).toBeDefined(); + expect(SAGA_STATES.compensating).toBeDefined(); + expect(SAGA_STATES.completed).toBeDefined(); + expect(SAGA_STATES.failed).toBeDefined(); + expect(SAGA_STATES.timeout).toBeDefined(); + }); + + it('each state has required properties', () => { + Object.values(SAGA_STATES).forEach(state => { + expect(state).toHaveProperty('label'); + expect(state).toHaveProperty('color'); + expect(state).toHaveProperty('bgColor'); + expect(state).toHaveProperty('icon'); + }); + }); + + it('has correct labels', () => { + expect(SAGA_STATES.created.label).toBe('Created'); + expect(SAGA_STATES.running.label).toBe('Running'); + expect(SAGA_STATES.completed.label).toBe('Completed'); + expect(SAGA_STATES.failed.label).toBe('Failed'); + }); + }); + + describe('getSagaStateInfo', () => { + it('returns correct info for known states', () => { + const running = getSagaStateInfo('running'); + expect(running.label).toBe('Running'); + expect(running.color).toBe('badge-info'); + }); + + it('returns default for unknown state', () => { + const unknown = getSagaStateInfo('unknown_state'); + expect(unknown.label).toBe('unknown_state'); + expect(unknown.color).toBe('badge-neutral'); + }); + }); + + describe('EXECUTION_SAGA_STEPS', () => { + it('has 5 steps', () => { + expect(EXECUTION_SAGA_STEPS).toHaveLength(5); + }); + + it('each step has required properties', () => { + EXECUTION_SAGA_STEPS.forEach(step => { + expect(step).toHaveProperty('name'); + expect(step).toHaveProperty('label'); + expect(step).toHaveProperty('compensation'); + }); + }); + + it('has correct step names', () => { + const names = EXECUTION_SAGA_STEPS.map(s => s.name); + expect(names).toContain('validate_execution'); + expect(names).toContain('allocate_resources'); + expect(names).toContain('create_pod'); + }); + + it('has compensations for some steps', () => { + const allocate = EXECUTION_SAGA_STEPS.find(s => s.name === 'allocate_resources'); + expect(allocate?.compensation).toBe('release_resources'); + + const validate = EXECUTION_SAGA_STEPS.find(s => s.name === 'validate_execution'); + expect(validate?.compensation).toBeNull(); + }); + }); + + describe('getSagaProgressPercentage', () => { + it('returns 0 for empty steps', () => { + expect(getSagaProgressPercentage([], 'execution_saga')).toBe(0); + }); + + it('returns 0 for null/undefined steps', () => { + expect(getSagaProgressPercentage(null as unknown as string[], 'execution_saga')).toBe(0); + expect(getSagaProgressPercentage(undefined as unknown as string[], 'execution_saga')).toBe(0); + }); + + it('calculates correct percentage for execution_saga', () => { + expect(getSagaProgressPercentage(['step1'], 'execution_saga')).toBe(20); + expect(getSagaProgressPercentage(['step1', 'step2'], 'execution_saga')).toBe(40); + expect(getSagaProgressPercentage(['s1', 's2', 's3', 's4', 's5'], 'execution_saga')).toBe(100); + }); + + it('calculates correct percentage for other sagas (3 steps)', () => { + expect(getSagaProgressPercentage(['step1'], 'other_saga')).toBeCloseTo(33.33, 0); + expect(getSagaProgressPercentage(['step1', 'step2', 'step3'], 'other_saga')).toBe(100); + }); + + it('caps at 100%', () => { + expect(getSagaProgressPercentage(['s1', 's2', 's3', 's4', 's5', 's6'], 'execution_saga')).toBe(100); + }); + }); +}); diff --git a/frontend/src/lib/admin/sagas/index.ts b/frontend/src/lib/admin/sagas/index.ts new file mode 100644 index 0000000..0897ec8 --- /dev/null +++ b/frontend/src/lib/admin/sagas/index.ts @@ -0,0 +1 @@ +export * from './sagaStates'; diff --git a/frontend/src/lib/admin/sagas/sagaStates.ts b/frontend/src/lib/admin/sagas/sagaStates.ts new file mode 100644 index 0000000..7c9a629 --- /dev/null +++ b/frontend/src/lib/admin/sagas/sagaStates.ts @@ -0,0 +1,83 @@ +/** + * Saga state configurations + */ +import { Plus, Loader, AlertTriangle, CheckCircle, XCircle, Clock } from '@lucide/svelte'; +import type { SagaState } from '../../api'; + +export interface SagaStateConfig { + label: string; + color: string; + bgColor: string; + icon: typeof CheckCircle; +} + +export const SAGA_STATES: Record = { + created: { + label: 'Created', + color: 'badge-neutral', + bgColor: 'bg-neutral-50 dark:bg-neutral-900/20', + icon: Plus + }, + running: { + label: 'Running', + color: 'badge-info', + bgColor: 'bg-blue-50 dark:bg-blue-900/20', + icon: Loader + }, + compensating: { + label: 'Compensating', + color: 'badge-warning', + bgColor: 'bg-yellow-50 dark:bg-yellow-900/20', + icon: AlertTriangle + }, + completed: { + label: 'Completed', + color: 'badge-success', + bgColor: 'bg-green-50 dark:bg-green-900/20', + icon: CheckCircle + }, + failed: { + label: 'Failed', + color: 'badge-danger', + bgColor: 'bg-red-50 dark:bg-red-900/20', + icon: XCircle + }, + timeout: { + label: 'Timeout', + color: 'badge-warning', + bgColor: 'bg-orange-50 dark:bg-orange-900/20', + icon: Clock + } +}; + +const DEFAULT_STATE: SagaStateConfig = { + label: 'Unknown', + color: 'badge-neutral', + bgColor: 'bg-neutral-50', + icon: Plus +}; + +export function getSagaStateInfo(state: SagaState | string): SagaStateConfig { + return SAGA_STATES[state] || { ...DEFAULT_STATE, label: state }; +} + +// Execution saga step definitions +export interface SagaStep { + name: string; + label: string; + compensation: string | null; +} + +export const EXECUTION_SAGA_STEPS: SagaStep[] = [ + { name: 'validate_execution', label: 'Validate', compensation: null }, + { name: 'allocate_resources', label: 'Allocate Resources', compensation: 'release_resources' }, + { name: 'queue_execution', label: 'Queue Execution', compensation: 'remove_from_queue' }, + { name: 'create_pod', label: 'Create Pod', compensation: 'delete_pod' }, + { name: 'monitor_execution', label: 'Monitor', compensation: null } +]; + +export function getSagaProgressPercentage(completedSteps: string[], sagaName: string): number { + if (!completedSteps?.length) return 0; + const totalSteps = sagaName === 'execution_saga' ? 5 : 3; + return Math.min(100, (completedSteps.length / totalSteps) * 100); +} diff --git a/frontend/src/lib/admin/users/__tests__/rateLimits.test.ts b/frontend/src/lib/admin/users/__tests__/rateLimits.test.ts new file mode 100644 index 0000000..f041ec3 --- /dev/null +++ b/frontend/src/lib/admin/users/__tests__/rateLimits.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; +import { + GROUP_COLORS, + getGroupColor, + ENDPOINT_GROUP_PATTERNS, + detectGroupFromEndpoint, + getDefaultRules, + getDefaultRulesWithMultiplier, + createEmptyRule +} from '../rateLimits'; + +describe('rateLimits', () => { + describe('GROUP_COLORS', () => { + it('has all expected groups', () => { + expect(GROUP_COLORS.execution).toBeDefined(); + expect(GROUP_COLORS.admin).toBeDefined(); + expect(GROUP_COLORS.sse).toBeDefined(); + expect(GROUP_COLORS.websocket).toBeDefined(); + expect(GROUP_COLORS.auth).toBeDefined(); + expect(GROUP_COLORS.api).toBeDefined(); + expect(GROUP_COLORS.public).toBeDefined(); + }); + + it('includes dark mode variants', () => { + expect(GROUP_COLORS.execution).toContain('dark:'); + }); + }); + + describe('getGroupColor', () => { + it('returns correct color for known groups', () => { + expect(getGroupColor('execution')).toBe(GROUP_COLORS.execution); + expect(getGroupColor('admin')).toBe(GROUP_COLORS.admin); + }); + + it('returns api color for unknown groups', () => { + expect(getGroupColor('unknown')).toBe(GROUP_COLORS.api); + }); + }); + + describe('ENDPOINT_GROUP_PATTERNS', () => { + it('has patterns for common endpoints', () => { + expect(ENDPOINT_GROUP_PATTERNS.length).toBeGreaterThan(0); + const groups = ENDPOINT_GROUP_PATTERNS.map(p => p.group); + expect(groups).toContain('execution'); + expect(groups).toContain('admin'); + expect(groups).toContain('auth'); + }); + }); + + describe('detectGroupFromEndpoint', () => { + it('detects execution endpoints', () => { + expect(detectGroupFromEndpoint('/api/v1/execute')).toBe('execution'); + expect(detectGroupFromEndpoint('^/api/v1/execute$')).toBe('execution'); + }); + + it('detects admin endpoints', () => { + expect(detectGroupFromEndpoint('/admin/users')).toBe('admin'); + expect(detectGroupFromEndpoint('/api/v1/admin/events')).toBe('admin'); + }); + + it('detects sse/events endpoints', () => { + expect(detectGroupFromEndpoint('/events/stream')).toBe('sse'); + expect(detectGroupFromEndpoint('/api/v1/events/123')).toBe('sse'); + }); + + it('detects websocket endpoints', () => { + expect(detectGroupFromEndpoint('/ws')).toBe('websocket'); + expect(detectGroupFromEndpoint('/api/v1/ws/connect')).toBe('websocket'); + }); + + it('detects auth endpoints', () => { + expect(detectGroupFromEndpoint('/auth/login')).toBe('auth'); + expect(detectGroupFromEndpoint('/api/v1/auth/token')).toBe('auth'); + }); + + it('detects public endpoints', () => { + expect(detectGroupFromEndpoint('/health')).toBe('public'); + expect(detectGroupFromEndpoint('/api/health/check')).toBe('public'); + }); + + it('returns api for unknown endpoints', () => { + expect(detectGroupFromEndpoint('/api/v1/users')).toBe('api'); + expect(detectGroupFromEndpoint('/some/random/path')).toBe('api'); + }); + + it('handles regex patterns correctly', () => { + expect(detectGroupFromEndpoint('^/api/v1/execute.*$')).toBe('execution'); + }); + }); + + describe('getDefaultRules', () => { + it('returns array of rules', () => { + const rules = getDefaultRules(); + expect(Array.isArray(rules)).toBe(true); + expect(rules.length).toBeGreaterThan(0); + }); + + it('each rule has required properties', () => { + const rules = getDefaultRules(); + rules.forEach(rule => { + expect(rule).toHaveProperty('endpoint_pattern'); + expect(rule).toHaveProperty('group'); + expect(rule).toHaveProperty('requests'); + expect(rule).toHaveProperty('window_seconds'); + expect(rule).toHaveProperty('algorithm'); + expect(rule).toHaveProperty('priority'); + }); + }); + + it('includes execution rule with 10 req/min', () => { + const rules = getDefaultRules(); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule).toBeDefined(); + expect(execRule?.requests).toBe(10); + }); + + it('includes api rule with 60 req/min', () => { + const rules = getDefaultRules(); + const apiRule = rules.find(r => r.group === 'api'); + expect(apiRule).toBeDefined(); + expect(apiRule?.requests).toBe(60); + }); + }); + + describe('getDefaultRulesWithMultiplier', () => { + it('returns rules with effective_requests', () => { + const rules = getDefaultRulesWithMultiplier(1.0); + rules.forEach(rule => { + expect(rule).toHaveProperty('effective_requests'); + }); + }); + + it('applies multiplier correctly', () => { + const rules = getDefaultRulesWithMultiplier(2.0); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule?.effective_requests).toBe(20); + + const apiRule = rules.find(r => r.group === 'api'); + expect(apiRule?.effective_requests).toBe(120); + }); + + it('handles 0.5 multiplier', () => { + const rules = getDefaultRulesWithMultiplier(0.5); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule?.effective_requests).toBe(5); + }); + + it('uses 1.0 as default multiplier', () => { + const rules = getDefaultRulesWithMultiplier(); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule?.effective_requests).toBe(10); + }); + + it('handles falsy multiplier as 1.0', () => { + const rules = getDefaultRulesWithMultiplier(0); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule?.effective_requests).toBe(10); + }); + + it('floors non-integer results', () => { + const rules = getDefaultRulesWithMultiplier(1.5); + const execRule = rules.find(r => r.group === 'execution'); + expect(execRule?.effective_requests).toBe(15); + + const rules2 = getDefaultRulesWithMultiplier(1.3); + const execRule2 = rules2.find(r => r.group === 'execution'); + expect(execRule2?.effective_requests).toBe(13); + }); + }); + + describe('createEmptyRule', () => { + it('returns rule with default values', () => { + const rule = createEmptyRule(); + expect(rule.endpoint_pattern).toBe(''); + expect(rule.group).toBe('api'); + expect(rule.requests).toBe(60); + expect(rule.window_seconds).toBe(60); + expect(rule.burst_multiplier).toBe(1.5); + expect(rule.algorithm).toBe('sliding_window'); + expect(rule.priority).toBe(0); + expect(rule.enabled).toBe(true); + }); + + it('returns new object each time', () => { + const a = createEmptyRule(); + const b = createEmptyRule(); + expect(a).not.toBe(b); + }); + }); +}); diff --git a/frontend/src/lib/admin/users/index.ts b/frontend/src/lib/admin/users/index.ts new file mode 100644 index 0000000..74b0616 --- /dev/null +++ b/frontend/src/lib/admin/users/index.ts @@ -0,0 +1 @@ +export * from './rateLimits'; diff --git a/frontend/src/lib/admin/users/rateLimits.ts b/frontend/src/lib/admin/users/rateLimits.ts new file mode 100644 index 0000000..66b2099 --- /dev/null +++ b/frontend/src/lib/admin/users/rateLimits.ts @@ -0,0 +1,76 @@ +/** + * Rate limit configurations and utilities for user management + */ +import type { RateLimitRule, EndpointGroup } from '../../api'; + +// Group colors for rate limit endpoint groups +export const GROUP_COLORS: Record = { + execution: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + admin: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + sse: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + websocket: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + auth: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + api: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200', + public: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' +}; + +export function getGroupColor(group: EndpointGroup | string): string { + return GROUP_COLORS[group] || GROUP_COLORS.api; +} + +// Patterns to detect endpoint groups from URL patterns +export const ENDPOINT_GROUP_PATTERNS: Array<{ pattern: RegExp; group: string }> = [ + { pattern: /\/execute/i, group: 'execution' }, + { pattern: /\/admin\//i, group: 'admin' }, + { pattern: /\/events\//i, group: 'sse' }, + { pattern: /\/ws/i, group: 'websocket' }, + { pattern: /\/auth\//i, group: 'auth' }, + { pattern: /\/health/i, group: 'public' } +]; + +export function detectGroupFromEndpoint(endpoint: string): string { + const cleanEndpoint = endpoint.replace(/^\^?/, '').replace(/\$?/, '').replace(/\.\*/g, ''); + for (const { pattern, group } of ENDPOINT_GROUP_PATTERNS) { + if (pattern.test(cleanEndpoint)) return group; + } + return 'api'; +} + +// Default rate limit rules +export interface DefaultRateLimitRule extends Omit { + effective_requests?: number; +} + +export function getDefaultRules(): DefaultRateLimitRule[] { + return [ + { endpoint_pattern: '^/api/v1/execute', group: 'execution', requests: 10, window_seconds: 60, algorithm: 'sliding_window', priority: 10 }, + { endpoint_pattern: '^/api/v1/admin/.*', group: 'admin', requests: 100, window_seconds: 60, algorithm: 'sliding_window', priority: 5 }, + { endpoint_pattern: '^/api/v1/events/.*', group: 'sse', requests: 5, window_seconds: 60, algorithm: 'sliding_window', priority: 8 }, + { endpoint_pattern: '^/api/v1/ws', group: 'websocket', requests: 5, window_seconds: 60, algorithm: 'sliding_window', priority: 8 }, + { endpoint_pattern: '^/api/v1/auth/.*', group: 'auth', requests: 20, window_seconds: 60, algorithm: 'sliding_window', priority: 7 }, + { endpoint_pattern: '^/api/v1/.*', group: 'api', requests: 60, window_seconds: 60, algorithm: 'sliding_window', priority: 1 } + ]; +} + +export function getDefaultRulesWithMultiplier(multiplier: number = 1.0): DefaultRateLimitRule[] { + const rules = getDefaultRules(); + const effectiveMultiplier = multiplier || 1.0; + return rules.map(rule => ({ + ...rule, + effective_requests: Math.floor(rule.requests * effectiveMultiplier) + })); +} + +// Create a new empty rate limit rule +export function createEmptyRule(): RateLimitRule { + return { + endpoint_pattern: '', + group: 'api', + requests: 60, + window_seconds: 60, + burst_multiplier: 1.5, + algorithm: 'sliding_window', + priority: 0, + enabled: true + }; +} diff --git a/frontend/src/routes/admin/AdminEvents.svelte b/frontend/src/routes/admin/AdminEvents.svelte index c623973..3deb987 100644 --- a/frontend/src/routes/admin/AdminEvents.svelte +++ b/frontend/src/routes/admin/AdminEvents.svelte @@ -16,87 +16,80 @@ } from '../../lib/api'; import { unwrap, unwrapOr } from '../../lib/api-interceptors'; import { addToast } from '../../stores/toastStore'; - import { formatTimestamp } from '../../lib/formatters'; import AdminLayout from './AdminLayout.svelte'; import Spinner from '../../components/Spinner.svelte'; - import Modal from '../../components/Modal.svelte'; - import EventTypeIcon from '../../components/EventTypeIcon.svelte'; + import { FilterPanel } from '../../components/admin'; import { - Filter, Download, RefreshCw, X, Eye, Play, Trash2, + EventStatsCards, + EventFilters, + EventsTable, + EventDetailsModal, + ReplayPreviewModal, + ReplayProgressBanner, + UserOverviewModal + } from '../../components/admin/events'; + import { + createDefaultEventFilters, + hasActiveFilters, + getActiveFilterCount, + getActiveFilterSummary, + type EventFilters as EventFiltersType + } from '../../lib/admin/events'; + import { + Filter, Download, RefreshCw, X, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, - FileText, Code, AlertTriangle + FileText, Code } from '@lucide/svelte'; + // State let events = $state([]); let loading = $state(false); let totalEvents = $state(0); + let stats = $state(null); + + // Pagination let currentPage = $state(1); let pageSize = $state(10); - let selectedEvent = $state(null); + let totalPages = $derived(Math.ceil(totalEvents / pageSize)); + let skip = $derived((currentPage - 1) * pageSize); + + // Filters let showFilters = $state(false); - let stats = $state(null); - let refreshInterval: ReturnType | null = null; + let filters = $state(createDefaultEventFilters()); + + // Modals + let selectedEvent = $state(null); + let showExportMenu = $state(false); + + // Replay state let activeReplaySession = $state(null); let replayCheckInterval: ReturnType | null = null; let replayPreview = $state<{ eventId: string; total_events: number; events_preview?: EventResponse[] } | null>(null); let showReplayPreview = $state(false); - let showExportMenu = $state(false); - // User overview modal state + // User overview modal let showUserOverview = $state(false); let userOverviewLoading = $state(false); - let selectedUserId = $state(null); let userOverview = $state(null); - // Filters - let filters = $state({ - event_types: [] as string[], - aggregate_id: '', - correlation_id: '', - user_id: '', - service_name: '', - search_text: '', - start_time: '', - end_time: '' - }); - - // Event type options - const eventTypes = [ - 'execution.requested', - 'execution.started', - 'execution.completed', - 'execution.failed', - 'execution.timeout', - 'pod.created', - 'pod.running', - 'pod.succeeded', - 'pod.failed', - 'pod.terminated' - ]; + // Auto-refresh + let refreshInterval: ReturnType | null = null; - let totalPages = $derived(Math.ceil(totalEvents / pageSize)); - let skip = $derived((currentPage - 1) * pageSize); - onMount(() => { loadEvents(); loadStats(); - // Auto-refresh every 30 seconds refreshInterval = setInterval(() => { loadEvents(); loadStats(); }, 30000); }); - + onDestroy(() => { - if (refreshInterval) { - clearInterval(refreshInterval); - } - if (replayCheckInterval) { - clearInterval(replayCheckInterval); - } + if (refreshInterval) clearInterval(refreshInterval); + if (replayCheckInterval) clearInterval(replayCheckInterval); }); - - async function loadEvents() { + + async function loadEvents(): Promise { loading = true; const data = unwrapOr(await browseEventsApiV1AdminEventsBrowsePost({ body: { @@ -155,7 +148,7 @@ if (dryRun) { if (response?.events_preview && response.events_preview.length > 0) { - replayPreview = { ...response, eventId: eventId }; + replayPreview = { ...response, eventId }; showReplayPreview = true; } else { addToast(`Dry run: ${response?.total_events} events would be replayed`, 'info'); @@ -201,7 +194,6 @@ async function openUserOverview(userId: string): Promise { if (!userId) return; - selectedUserId = userId; userOverview = null; showUserOverview = true; userOverviewLoading = true; @@ -211,82 +203,41 @@ userOverview = data; } - function getEventTypeColor(eventType: string): string { - if (eventType.includes('.completed') || eventType.includes('.succeeded')) return 'text-green-600 dark:text-green-400'; - if (eventType.includes('.failed') || eventType.includes('.timeout')) return 'text-red-600 dark:text-red-400'; - if (eventType.includes('.started') || eventType.includes('.running')) return 'text-blue-600 dark:text-blue-400'; - if (eventType.includes('.requested')) return 'text-purple-600 dark:text-purple-400'; - if (eventType.includes('.created')) return 'text-indigo-600 dark:text-indigo-400'; - if (eventType.includes('.terminated')) return 'text-orange-600 dark:text-orange-400'; - return 'text-neutral-600 dark:text-neutral-400'; + function clearFilters(): void { + filters = createDefaultEventFilters(); + currentPage = 1; + loadEvents(); } - function getEventTypeLabel(eventType: string): string { - // For execution.requested, show icon only (with tooltip) - if (eventType === 'execution.requested') { - return ''; - } - - // For all other events, show full name - const parts = eventType.split('.'); - if (parts.length === 2) { - return `${parts[0]}.${parts[1]}`; - } - return eventType; - } - - function clearFilters(): void { - filters = { - event_types: [], - aggregate_id: '', - correlation_id: '', - user_id: '', - service_name: '', - search_text: '', - start_time: '', - end_time: '' - }; + function applyFilters(): void { currentPage = 1; loadEvents(); } - - function getActiveFilterCount(): number { - let count = 0; - if (filters.event_types.length > 0) count++; - if (filters.search_text) count++; - if (filters.correlation_id) count++; - if (filters.aggregate_id) count++; - if (filters.user_id) count++; - if (filters.service_name) count++; - if (filters.start_time) count++; - if (filters.end_time) count++; - return count; + + function handlePreviewReplay(eventId: string): void { + replayEvent(eventId, true); } - - function hasActiveFilters(): boolean { - return getActiveFilterCount() > 0; + + function handleReplay(eventId: string): void { + replayEvent(eventId, false); } - function getActiveFilterSummary(): string { - const items: string[] = []; - if (filters.event_types.length > 0) { - items.push(`${filters.event_types.length} event type${filters.event_types.length > 1 ? 's' : ''}`); - } - if (filters.search_text) items.push('search'); - if (filters.correlation_id) items.push('correlation'); - if (filters.aggregate_id) items.push('aggregate'); - if (filters.user_id) items.push('user'); - if (filters.service_name) items.push('service'); - if (filters.start_time || filters.end_time) items.push('time range'); - return items; + function handleReplayFromModal(eventId: string): void { + selectedEvent = null; + replayEvent(eventId, false); + } + + function handleReplayConfirm(eventId: string): void { + replayEvent(eventId, false); }
+

Event Browser

- +
- +
- - {#if activeReplaySession} -
- - - -
-

Replay in Progress

- - {activeReplaySession.status} - -
-
-
- Progress: {activeReplaySession.replayed_events} / {activeReplaySession.total_events} events - {activeReplaySession.progress_percentage}% -
-
-
-
-
- {#if activeReplaySession.failed_events > 0} -
-
- Failed: {activeReplaySession.failed_events} events -
- {#if activeReplaySession.error_message} -
-

- Error: {activeReplaySession.error_message} -

-
- {/if} - {#if activeReplaySession.failed_event_errors && activeReplaySession.failed_event_errors.length > 0} -
- {#each activeReplaySession.failed_event_errors as error} -
-
{error.event_id}
-
{error.error}
-
- {/each} -
- {/if} -
- {/if} - - {#if activeReplaySession.execution_results && activeReplaySession.execution_results.length > 0} -
-

Execution Results:

-
- {#each activeReplaySession.execution_results as result} -
-
-
- {result.execution_id} -
- - {result.status} - - {#if result.execution_time} - - {result.execution_time.toFixed(2)}s - - {/if} -
-
- {#if result.output || result.errors} -
- {#if result.output} -
- Output: {result.output} -
- {/if} - {#if result.errors} -
- Error: {result.errors} -
- {/if} -
- {/if} -
-
- {/each} -
-
- {/if} -
- {/if} - - {#if stats} -
-
-
Events (Last 24h)
-
{stats?.total_events?.toLocaleString() || '0'}
-
of {totalEvents?.toLocaleString() || '0'} total
-
- -
-
Error Rate (24h)
-
{stats?.error_rate || 0}%
-
- -
-
Avg Execution Time (24h)
-
{stats?.avg_processing_time ? stats.avg_processing_time.toFixed(2) : '0'}s
-
- -
-
Active Users (24h)
-
{stats?.top_users?.length || 0}
-
with events
-
-
- {/if} - - {#if !showFilters && hasActiveFilters()} + + activeReplaySession = null} /> + + + + + {#if !showFilters && hasActiveFilters(filters)}
Active filters: - {#each getActiveFilterSummary() as filter} + {#each getActiveFilterSummary(filters) as filter} {filter} @@ -500,351 +321,32 @@
{/if} - - {#if showFilters} -
-
-
-

Filter Events

-
- - -
-
- -
- -
-
- - -
- -
- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
- -
- - -
- -
- - -
-
-
-
-
+ {#if showFilters} + {/if} - + +
-

- Events -

-
- - - - -
- {#each events || [] as event} -
loadEventDetail(event.event_id)} - onkeydown={(e) => e.key === 'Enter' && loadEventDetail(event.event_id)} - tabindex="0" - role="button" - aria-label="View event details" - > -
-
-
-
- - - - - -
-
- {formatTimestamp(event.timestamp)} -
-
-
-
- - - -
-
-
-
- User: - {#if event.metadata?.user_id} - - {:else} - - - {/if} -
-
- Service: - - {event.metadata?.service_name || '-'} - -
-
- Correlation: - - {event.correlation_id} - -
-
-
- {/each} -
- - {#if events.length === 0} -
- No events found -
- {/if} - - - {#if totalEvents > 0} + + + + {#if totalEvents > 0}
-
-
- Auto-refresh - + - {#if autoRefresh} -
- - -
- {/if} - - -
+
+
- -
-
- - -
-
- - + + + + + {#if totalItems > 0} +
+
-
- - -
-
- Actions - -
-
- - -
- {#if loading && sagas.length === 0} -
- -

Loading sagas...

-
- {:else if sagas.length === 0} -
No sagas found
- {:else} - -
- {#each sagas as saga} -
-
-
-
{saga.saga_name}
-
ID: {saga.saga_id.slice(0, 12)}...
-
- - {getStateInfo(saga.state).label} - {#if saga.retry_count > 0}({saga.retry_count}){/if} - -
-
-
- Started: -
{formatTimestamp(saga.created_at)}
-
-
- Duration: -
{formatDurationBetween(saga.created_at, saga.completed_at || saga.updated_at)}
-
-
-
-
- Progress: {saga.completed_steps.length} steps - {Math.round(getProgressPercentage(saga))}% -
-
-
-
- - -
-
- {/each} -
- - - - - - {#if totalItems > 0} -
- -
- {/if} - {/if} -
+ {/if}
- {#if showDetailModal && selectedSaga} - showDetailModal = false} size="lg"> - {#snippet children()} -
-
-

Basic Information

-
-
Saga ID
{selectedSaga.saga_id}
-
Saga Name
{selectedSaga.saga_name}
-
Execution ID
-
-
-
State
{getStateInfo(selectedSaga.state).label}
-
Retry Count
{selectedSaga.retry_count}
-
-
-
-

Timing Information

-
-
Created At
{formatTimestamp(selectedSaga.created_at)}
-
Updated At
{formatTimestamp(selectedSaga.updated_at)}
-
Completed At
{formatTimestamp(selectedSaga.completed_at)}
-
Duration
{formatDurationBetween(selectedSaga.created_at, selectedSaga.completed_at || selectedSaga.updated_at)}
-
-
-
- -
-

Execution Steps

- - {#if selectedSaga.saga_name === 'execution_saga'} -
-
- {#each executionSagaSteps as step, index} - {@const isCompleted = selectedSaga.completed_steps.includes(step.name)} - {@const isCompensated = step.compensation && selectedSaga.compensated_steps.includes(step.compensation)} - {@const isCurrent = selectedSaga.current_step === step.name} - {@const isFailed = selectedSaga.state === 'failed' && isCurrent} - -
-
-
- {#if index > 0} -
- {/if} -
-
-
- {#if isCompleted} - {:else if isCompensated} - {:else if isCurrent}
- {:else if isFailed} - {:else}{index + 1}{/if} -
-
-
- {#if index < executionSagaSteps.length - 1} -
- {/if} -
-
-
- {step.label} - {#if step.compensation && isCompensated}
(compensated)
{/if} -
-
- {/each} -
-
- {/if} - - {#if selectedSaga.current_step} -
Current Step: {selectedSaga.current_step}
- {/if} - -
-
-

Completed ({selectedSaga.completed_steps.length})

- {#if selectedSaga.completed_steps.length > 0} -
    {#each selectedSaga.completed_steps as step}
  • {step}
  • {/each}
- {:else}

No completed steps

{/if} -
-
-

Compensated ({selectedSaga.compensated_steps.length})

- {#if selectedSaga.compensated_steps.length > 0} -
    {#each selectedSaga.compensated_steps as step}
  • {step}
  • {/each}
- {:else}

No compensated steps

{/if} -
-
-
- - {#if selectedSaga.error_message} -
-

Error Information

-
{selectedSaga.error_message}
-
- {/if} - - {#if selectedSaga.context_data && Object.keys(selectedSaga.context_data).length > 0} -
-

Context Data

-
-
{JSON.stringify(selectedSaga.context_data, null, 2)}
-
-
- {/if} - {/snippet} -
+ showDetailModal = false} + onViewExecution={handleViewExecution} + /> {/if} diff --git a/frontend/src/routes/admin/AdminUsers.svelte b/frontend/src/routes/admin/AdminUsers.svelte index 0fa1e74..e6467af 100644 --- a/frontend/src/routes/admin/AdminUsers.svelte +++ b/frontend/src/routes/admin/AdminUsers.svelte @@ -10,34 +10,50 @@ resetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPost, type UserResponse, type UserRateLimit, - type RateLimitRule, - type EndpointGroup, } from '../../lib/api'; import { unwrap, unwrapOr } from '../../lib/api-interceptors'; import { addToast } from '../../stores/toastStore'; - import { formatTimestamp } from '../../lib/formatters'; import AdminLayout from './AdminLayout.svelte'; import Spinner from '../../components/Spinner.svelte'; - import Modal from '../../components/Modal.svelte'; import Pagination from '../../components/Pagination.svelte'; - import { Plus, RefreshCw, Pencil, Clock, Trash2, ChevronDown, X } from '@lucide/svelte'; - + import { Plus, RefreshCw } from '@lucide/svelte'; + import { + UserFilters, + UsersTable, + UserFormModal, + DeleteUserModal, + RateLimitsModal + } from '../../components/admin/users'; + + // User list state let users = $state([]); let loading = $state(false); + + // Modal states let showDeleteModal = $state(false); let showRateLimitModal = $state(false); + let showUserModal = $state(false); let userToDelete = $state(null); let rateLimitUser = $state(null); + let editingUser = $state(null); + + // Rate limit state let rateLimitConfig = $state(null); - let rateLimitUsage = $state | null>(null); - let cascadeDelete = $state(true); - let deletingUser = $state(false); + let rateLimitUsage = $state | null>(null); let loadingRateLimits = $state(false); let savingRateLimits = $state(false); + // User form state + let userForm = $state({ username: '', email: '', password: '', role: 'user', is_active: true }); + let savingUser = $state(false); + let cascadeDelete = $state(true); + let deletingUser = $state(false); + + // Pagination let currentPage = $state(1); let pageSize = $state(10); + // Filters let searchQuery = $state(''); let roleFilter = $state('all'); let statusFilter = $state('all'); @@ -48,14 +64,18 @@ globalMultiplier: 'all' as string }); - let showUserModal = $state(false); - let editingUser = $state(null); - let userForm = $state({ username: '', email: '', password: '', role: 'user', is_active: true }); - let savingUser = $state(false); - + // Derived state let filteredUsers = $derived(filterUsers(users, searchQuery, roleFilter, statusFilter, advancedFilters)); let totalPages = $derived(Math.ceil(filteredUsers.length / pageSize)); let paginatedUsers = $derived(filteredUsers.slice((currentPage - 1) * pageSize, currentPage * pageSize)); + let hasFiltersActive = $derived( + searchQuery !== '' || + roleFilter !== 'all' || + statusFilter !== 'all' || + advancedFilters.bypassRateLimit !== 'all' || + advancedFilters.hasCustomLimits !== 'all' || + advancedFilters.globalMultiplier !== 'all' + ); onMount(() => { loadUsers(); }); @@ -66,117 +86,19 @@ users = data ? (Array.isArray(data) ? data : data?.users || []) : []; } - async function deleteUser(): Promise { - if (!userToDelete) return; - deletingUser = true; - const result = await deleteUserApiV1AdminUsersUserIdDelete({ - path: { user_id: userToDelete.user_id }, - query: { cascade: cascadeDelete } - }); - deletingUser = false; - unwrap(result); - await loadUsers(); - showDeleteModal = false; - userToDelete = null; - } - - async function openRateLimitModal(user: UserResponse): Promise { - rateLimitUser = user; - showRateLimitModal = true; - loadingRateLimits = true; - const result = await getUserRateLimitsApiV1AdminUsersUserIdRateLimitsGet({ - path: { user_id: user.user_id } - }); - loadingRateLimits = false; - const response = unwrap(result); - rateLimitConfig = response?.rate_limit_config || { - user_id: user.user_id, rules: [], global_multiplier: 1.0, bypass_rate_limit: false, notes: '' - }; - rateLimitUsage = response?.current_usage || {}; - } - - async function saveRateLimits(): Promise { - if (!rateLimitUser || !rateLimitConfig) return; - savingRateLimits = true; - const result = await updateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPut({ - path: { user_id: rateLimitUser.user_id }, - body: rateLimitConfig - }); - savingRateLimits = false; - unwrap(result); - showRateLimitModal = false; + interface AdvancedFilters { + bypassRateLimit: string; + hasCustomLimits: string; + globalMultiplier: string; } - async function resetRateLimits(): Promise { - if (!rateLimitUser) return; - unwrap(await resetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPost({ - path: { user_id: rateLimitUser.user_id } - })); - rateLimitUsage = {}; - } - - let defaultRulesWithEffective = $derived(getDefaultRulesWithMultiplier(rateLimitConfig?.global_multiplier)); - - function getDefaultRulesWithMultiplier(multiplier: number): RateLimitRule[] { - const rules = [ - { endpoint_pattern: '^/api/v1/execute', group: 'execution', requests: 10, window_seconds: 60, algorithm: 'sliding_window', priority: 10 }, - { endpoint_pattern: '^/api/v1/admin/.*', group: 'admin', requests: 100, window_seconds: 60, algorithm: 'sliding_window', priority: 5 }, - { endpoint_pattern: '^/api/v1/events/.*', group: 'sse', requests: 5, window_seconds: 60, algorithm: 'sliding_window', priority: 8 }, - { endpoint_pattern: '^/api/v1/ws', group: 'websocket', requests: 5, window_seconds: 60, algorithm: 'sliding_window', priority: 8 }, - { endpoint_pattern: '^/api/v1/auth/.*', group: 'auth', requests: 20, window_seconds: 60, algorithm: 'sliding_window', priority: 7 }, - { endpoint_pattern: '^/api/v1/.*', group: 'api', requests: 60, window_seconds: 60, algorithm: 'sliding_window', priority: 1 } - ]; - const effectiveMultiplier = multiplier || 1.0; - return rules.map(rule => ({ ...rule, effective_requests: Math.floor(rule.requests * effectiveMultiplier) })); - } - - const groupColors: Record = { - execution: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', - admin: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - sse: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', - websocket: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', - auth: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', - api: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200', - public: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' - }; - - function getGroupColor(group: EndpointGroup | string): string { - return groupColors[group] || groupColors.api; - } - - const endpointGroupPatterns = [ - { pattern: /\/execute/i, group: 'execution' }, { pattern: /\/admin\//i, group: 'admin' }, - { pattern: /\/events\//i, group: 'sse' }, { pattern: /\/ws/i, group: 'websocket' }, - { pattern: /\/auth\//i, group: 'auth' }, { pattern: /\/health/i, group: 'public' } - ]; - - function detectGroupFromEndpoint(endpoint: string): string { - const cleanEndpoint = endpoint.replace(/^\^?/, '').replace(/\$?/, '').replace(/\.\*/g, ''); - for (const { pattern, group } of endpointGroupPatterns) { - if (pattern.test(cleanEndpoint)) return group; - } - return 'api'; - } - - function handleEndpointChange(rule: RateLimitRule): void { - if (rule.endpoint_pattern) rule.group = detectGroupFromEndpoint(rule.endpoint_pattern); - } - - function addNewRule(): void { - if (!rateLimitConfig?.rules) rateLimitConfig!.rules = []; - rateLimitConfig!.rules = [...rateLimitConfig!.rules, { - endpoint_pattern: '', group: 'api', requests: 60, window_seconds: 60, - burst_multiplier: 1.5, algorithm: 'sliding_window', priority: 0, enabled: true - }]; - } - - function removeRule(index: number): void { - rateLimitConfig!.rules = rateLimitConfig!.rules!.filter((_, i) => i !== index); - } - - interface AdvancedFilters { bypassRateLimit: string; hasCustomLimits: string; globalMultiplier: string; } - - function filterUsers(userList: UserResponse[], search: string, role: string, status: string, advanced: AdvancedFilters): UserResponse[] { + function filterUsers( + userList: UserResponse[], + search: string, + role: string, + status: string, + advanced: AdvancedFilters + ): UserResponse[] { let filtered = [...userList]; if (search) { const searchLower = search.toLowerCase(); @@ -198,6 +120,7 @@ return filtered; } + // User CRUD function openCreateUserModal(): void { editingUser = null; userForm = { username: '', email: '', password: '', role: 'user', is_active: true }; @@ -232,15 +155,73 @@ await loadUsers(); } + async function deleteUser(): Promise { + if (!userToDelete) return; + deletingUser = true; + const result = await deleteUserApiV1AdminUsersUserIdDelete({ + path: { user_id: userToDelete.user_id }, + query: { cascade: cascadeDelete } + }); + deletingUser = false; + unwrap(result); + await loadUsers(); + showDeleteModal = false; + userToDelete = null; + } + + // Rate limits + async function openRateLimitModal(user: UserResponse): Promise { + rateLimitUser = user; + showRateLimitModal = true; + loadingRateLimits = true; + const result = await getUserRateLimitsApiV1AdminUsersUserIdRateLimitsGet({ + path: { user_id: user.user_id } + }); + loadingRateLimits = false; + const response = unwrap(result); + rateLimitConfig = response?.rate_limit_config || { + user_id: user.user_id, rules: [], global_multiplier: 1.0, bypass_rate_limit: false, notes: '' + }; + rateLimitUsage = response?.current_usage || {}; + } + + async function saveRateLimits(): Promise { + if (!rateLimitUser || !rateLimitConfig) return; + savingRateLimits = true; + const result = await updateUserRateLimitsApiV1AdminUsersUserIdRateLimitsPut({ + path: { user_id: rateLimitUser.user_id }, + body: rateLimitConfig + }); + savingRateLimits = false; + unwrap(result); + showRateLimitModal = false; + } + + async function resetRateLimits(): Promise { + if (!rateLimitUser) return; + unwrap(await resetUserRateLimitsApiV1AdminUsersUserIdRateLimitsResetPost({ + path: { user_id: rateLimitUser.user_id } + })); + rateLimitUsage = {}; + } + function handlePageChange(page: number): void { currentPage = page; } function handlePageSizeChange(size: number): void { pageSize = size; currentPage = 1; } function resetFilters(): void { - searchQuery = ''; roleFilter = 'all'; statusFilter = 'all'; + searchQuery = ''; + roleFilter = 'all'; + statusFilter = 'all'; advancedFilters = { bypassRateLimit: 'all', hasCustomLimits: 'all', globalMultiplier: 'all' }; currentPage = 1; } + function handleDelete(user: UserResponse): void { + userToDelete = user; + showDeleteModal = true; + } + + // Reset page when filter changes let prevFilters = { searchQuery: '', roleFilter: 'all', statusFilter: 'all' }; $effect(() => { if (searchQuery !== prevFilters.searchQuery || roleFilter !== prevFilters.roleFilter || statusFilter !== prevFilters.statusFilter) { @@ -264,64 +245,15 @@
- -
-
-
-
- - -
-
- - -
-
- - -
- - -
- - {#if showAdvancedFilters} -
-

Rate Limit Filters

-
-
- - -
-
- - -
-
- - -
-
-
- {/if} -
-
+
@@ -329,86 +261,26 @@ Users ({filteredUsers.length}{filteredUsers.length !== users.length ? ` of ${users.length}` : ''})

- {#if loading} -
Loading users...
- {:else if filteredUsers.length === 0} -
No users found matching filters
- {:else} - -
- {#each paginatedUsers as user} -
-
-
-
{user.username}
-
{user.email || 'No email'}
-
-
- {user.role} - {user.is_active ? 'Active' : 'Inactive'} -
-
-
Created: {formatTimestamp(user.created_at)}
-
- - - -
-
- {/each} -
- - - - {/if} + {#if totalPages > 1 || filteredUsers.length > 0}
- +
{/if}
@@ -416,220 +288,38 @@
- {#if showDeleteModal && userToDelete} - { showDeleteModal = false; userToDelete = null; }} size="sm"> - {#snippet children()} -

- Are you sure you want to delete user {userToDelete.username}? -

-
- -
- {#if cascadeDelete} -
- Warning: This will permanently delete all data associated with this user. -
- {/if} -
- - -
- {/snippet} -
+ { showDeleteModal = false; userToDelete = null; }} + onDelete={deleteUser} + /> {/if} - {#if showRateLimitModal && rateLimitUser} - { showRateLimitModal = false; rateLimitUser = null; rateLimitConfig = null; }} size="xl"> - {#snippet children()} - {#if loadingRateLimits} -
- {:else if rateLimitConfig} -
- -
-
-

Quick Settings

- -
-
-
- - -

Multiplies all limits (1.0 = default, 2.0 = double)

-
-
- - -
-
-
- - -
-
-

Endpoint Rate Limits

- -
- - -
-
Default Global Rules
-
- {#each defaultRulesWithEffective || [] as rule} -
-
-
- Endpoint -

{rule.endpoint_pattern}

-
-
- Limit -

- {#if rateLimitConfig?.global_multiplier !== 1.0} - {rule.requests} - {rule.effective_requests} - {:else}{rule.requests}{/if} req / {rule.window_seconds}s -

-
-
- Group - {rule.group} -
-
- Algorithm -

{rule.algorithm}

-
-
-
- {/each} -
-
- - - {#if rateLimitConfig.rules && rateLimitConfig.rules.length > 0} -
-
User-Specific Overrides
-
- {#each rateLimitConfig.rules as rule, index} -
-
-
- handleEndpointChange(rule)} - placeholder="Endpoint pattern" class="input input-sm w-full" disabled={rateLimitConfig.bypass_rate_limit} /> -
-
- - / - - s - {#if rateLimitConfig.global_multiplier !== 1.0} - (→ {Math.floor(rule.requests * rateLimitConfig.global_multiplier)}/{rule.window_seconds}s) - {/if} -
- {rule.group} - - - -
-
- {/each} -
-
- {/if} -
- - - {#if rateLimitUsage && Object.keys(rateLimitUsage).length > 0} -
-
-

Current Usage

- -
-
- {#each Object.entries(rateLimitUsage) as [endpoint, usage]} -
- {endpoint} - {usage.count || usage.tokens_remaining || 0} -
- {/each} -
-
- {/if} -
-
- - -
- {/if} - {/snippet} -
+ { showRateLimitModal = false; rateLimitUser = null; rateLimitConfig = null; }} + onSave={saveRateLimits} + onReset={resetRateLimits} + /> {/if} - {#if showUserModal} - showUserModal = false} size="sm"> - {#snippet children()} -
{ e.preventDefault(); saveUser(); }}> -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- - -
-
- {/snippet} -
+ showUserModal = false} + onSave={saveUser} + /> {/if} diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts new file mode 100644 index 0000000..7d3dd6c --- /dev/null +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -0,0 +1,495 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { tick } from 'svelte'; + +function createMockSaga(overrides: Partial<{ + saga_id: string; + saga_name: string; + execution_id: string; + state: string; + current_step: string; + completed_steps: string[]; + compensated_steps: string[]; + retry_count: number; + error_message: string | null; + context_data: Record; + created_at: string; + updated_at: string; + completed_at: string | null; +}> = {}) { + return { + saga_id: 'saga-1', + saga_name: 'execution_saga', + execution_id: 'exec-123', + state: 'running', + current_step: 'create_pod', + completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'], + compensated_steps: [], + retry_count: 0, + error_message: null, + context_data: { key: 'value' }, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:31:00Z', + completed_at: null, + ...overrides, + }; +} + +function createMockSagas(count: number) { + const states = ['created', 'running', 'completed', 'failed', 'compensating', 'timeout']; + return Array.from({ length: count }, (_, i) => + createMockSaga({ + saga_id: `saga-${i + 1}`, + execution_id: `exec-${i + 1}`, + state: states[i % states.length], + created_at: new Date(Date.now() - i * 60000).toISOString(), + updated_at: new Date(Date.now() - i * 30000).toISOString(), + }) + ); +} + +const mocks = vi.hoisted(() => ({ + listSagasApiV1SagasGet: vi.fn(), + getSagaStatusApiV1SagasSagaIdGet: vi.fn(), + getExecutionSagasApiV1SagasExecutionExecutionIdGet: vi.fn(), +})); + +vi.mock('../../../lib/api', () => ({ + listSagasApiV1SagasGet: (...args: unknown[]) => mocks.listSagasApiV1SagasGet(...args), + getSagaStatusApiV1SagasSagaIdGet: (...args: unknown[]) => mocks.getSagaStatusApiV1SagasSagaIdGet(...args), + getExecutionSagasApiV1SagasExecutionExecutionIdGet: (...args: unknown[]) => mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet(...args), +})); + +vi.mock('../../../lib/api-interceptors'); + +vi.mock('@mateothegreat/svelte5-router', () => ({ + route: () => {}, + goto: vi.fn(), +})); + +vi.mock('../AdminLayout.svelte', async () => { + const { default: MockLayout } = await import('./mocks/MockAdminLayout.svelte'); + return { default: MockLayout }; +}); + +import AdminSagas from '../AdminSagas.svelte'; + +function setupMocks() { + Element.prototype.animate = vi.fn().mockImplementation(() => ({ + onfinish: null, cancel: vi.fn(), finish: vi.fn(), pause: vi.fn(), play: vi.fn(), + reverse: vi.fn(), commitStyles: vi.fn(), persist: vi.fn(), currentTime: 0, + playbackRate: 1, pending: false, playState: 'running', replaceState: 'active', + startTime: 0, timeline: null, id: '', effect: null, + addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(() => true), + updatePlaybackRate: vi.fn(), + get finished() { return Promise.resolve(this); }, + get ready() { return Promise.resolve(this); }, + oncancel: null, onremove: null, + })); +} + +async function renderWithSagas(sagas = createMockSagas(5)) { + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: sagas.length }, + error: null, + }); + + const result = render(AdminSagas); + await tick(); + await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); + return result; +} + +describe('AdminSagas', () => { + beforeEach(() => { + vi.useFakeTimers(); + setupMocks(); + vi.clearAllMocks(); + mocks.listSagasApiV1SagasGet.mockResolvedValue({ data: { sagas: [], total: 0 }, error: null }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: null, error: null }); + mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet.mockResolvedValue({ data: { sagas: [], total: 0 }, error: null }); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + describe('initial loading', () => { + it('calls listSagas on mount', async () => { + render(AdminSagas); + await tick(); + await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); + }); + + it('displays loading state', async () => { + mocks.listSagasApiV1SagasGet.mockImplementation(() => new Promise(() => {})); + render(AdminSagas); + await tick(); + expect(screen.getByText(/loading sagas/i)).toBeInTheDocument(); + }); + + it('displays empty state when no sagas', async () => { + await renderWithSagas([]); + expect(screen.getByText(/no sagas found/i)).toBeInTheDocument(); + }); + }); + + describe('saga list rendering', () => { + it('displays saga data in table', async () => { + const sagas = [createMockSaga({ saga_name: 'test_saga', state: 'running' })]; + await renderWithSagas(sagas); + expect(screen.getAllByText('test_saga').length).toBeGreaterThan(0); + }); + + it('displays multiple sagas', async () => { + const sagas = createMockSagas(3); + await renderWithSagas(sagas); + expect(screen.getAllByText(/saga-1/).length).toBeGreaterThan(0); + }); + + it('shows state badges with correct labels', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', state: 'completed' }), + createMockSaga({ saga_id: 's2', state: 'failed' }), + createMockSaga({ saga_id: 's3', state: 'running' }), + ]; + await renderWithSagas(sagas); + expect(screen.getAllByText('Completed').length).toBeGreaterThan(0); + expect(screen.getAllByText('Failed').length).toBeGreaterThan(0); + expect(screen.getAllByText('Running').length).toBeGreaterThan(0); + }); + + it('shows retry count when > 0', async () => { + const sagas = [createMockSaga({ retry_count: 3 })]; + await renderWithSagas(sagas); + expect(screen.getByText('(3)', { exact: false })).toBeInTheDocument(); + }); + }); + + describe('stats cards', () => { + it('displays state counts', async () => { + const sagas = [ + createMockSaga({ saga_id: 's1', state: 'completed' }), + createMockSaga({ saga_id: 's2', state: 'completed' }), + createMockSaga({ saga_id: 's3', state: 'failed' }), + ]; + await renderWithSagas(sagas); + const statsSection = screen.getByTestId('admin-layout'); + expect(statsSection).toBeInTheDocument(); + }); + }); + + describe('auto-refresh', () => { + it('auto-refreshes at specified interval', async () => { + await renderWithSagas(); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5000); + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(3); + }); + + it('manual refresh button works', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await renderWithSagas(); + vi.clearAllMocks(); + + const refreshButton = screen.getByRole('button', { name: /refresh now/i }); + await user.click(refreshButton); + + await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); + }); + + it('toggling auto-refresh off stops polling', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await renderWithSagas(); + + const checkbox = screen.getByRole('checkbox', { name: /auto-refresh/i }); + await user.click(checkbox); + + vi.clearAllMocks(); + await vi.advanceTimersByTimeAsync(10000); + + expect(mocks.listSagasApiV1SagasGet).not.toHaveBeenCalled(); + }); + }); + + describe('filters', () => { + it('filters by state', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await renderWithSagas(); + vi.clearAllMocks(); + + const stateSelect = screen.getByLabelText(/state/i); + await user.selectOptions(stateSelect, 'running'); + + await waitFor(() => { + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ state: 'running' }) + }) + ); + }); + }); + + it('filters by search query', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const sagas = [ + createMockSaga({ saga_id: 's1', saga_name: 'alpha_saga' }), + createMockSaga({ saga_id: 's2', saga_name: 'beta_saga' }), + ]; + await renderWithSagas(sagas); + + const searchInput = screen.getByLabelText(/search/i); + await user.type(searchInput, 'alpha'); + + await waitFor(() => { + expect(screen.getAllByText('alpha_saga').length).toBeGreaterThan(0); + }); + }); + + it('filters by execution ID', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const sagas = [ + createMockSaga({ saga_id: 's1', execution_id: 'exec-abc' }), + createMockSaga({ saga_id: 's2', execution_id: 'exec-xyz' }), + ]; + await renderWithSagas(sagas); + + const execInput = screen.getByLabelText(/execution id/i); + await user.type(execInput, 'abc'); + + await waitFor(() => { + expect(screen.getAllByText(/exec-abc/).length).toBeGreaterThan(0); + }); + }); + + it('clears filters on clear button click', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await renderWithSagas(); + + const stateSelect = screen.getByLabelText(/state/i); + await user.selectOptions(stateSelect, 'failed'); + + vi.clearAllMocks(); + const clearButton = screen.getByRole('button', { name: /clear filters/i }); + await user.click(clearButton); + + await waitFor(() => { + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ state: undefined }) + }) + ); + }); + }); + }); + + describe('saga details modal', () => { + it('opens modal on View Details click', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + saga_name: 'execution_saga', + completed_steps: ['validate_execution', 'allocate_resources'], + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(screen.getByText('Saga Details')).toBeInTheDocument(); + }); + + it('displays saga information in modal', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + saga_id: 'saga-detail-test', + saga_name: 'execution_saga', + state: 'completed', + completed_steps: ['validate_execution'], + retry_count: 2, + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => { + expect(screen.getByText('saga-detail-test')).toBeInTheDocument(); + }); + }); + + it('shows error message when saga has error', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + state: 'failed', + error_message: 'Pod creation failed: timeout', + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => { + expect(screen.getByText(/pod creation failed/i)).toBeInTheDocument(); + }); + }); + + it('shows context data when available', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + context_data: { user_id: 'user-123', language: 'python' }, + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => { + expect(screen.getByText(/context data/i)).toBeInTheDocument(); + expect(screen.getByText(/user-123/)).toBeInTheDocument(); + }); + }); + + it('closes modal on close button click', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga(); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await user.click(screen.getByLabelText(/close modal/i)); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('displays execution saga step visualization', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + saga_name: 'execution_saga', + completed_steps: ['validate_execution', 'allocate_resources'], + current_step: 'queue_execution', + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => { + expect(screen.getByText('Validate')).toBeInTheDocument(); + expect(screen.getByText('Allocate Resources')).toBeInTheDocument(); + expect(screen.getByText('Queue Execution')).toBeInTheDocument(); + }); + }); + + it('shows compensated steps', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const saga = createMockSaga({ + saga_name: 'execution_saga', + state: 'failed', + completed_steps: ['validate_execution', 'allocate_resources'], + compensated_steps: ['release_resources'], + }); + mocks.getSagaStatusApiV1SagasSagaIdGet.mockResolvedValue({ data: saga, error: null }); + await renderWithSagas([saga]); + + const viewButtons = screen.getAllByText(/view details/i); + await user.click(viewButtons[0]); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(screen.getByText('release_resources')).toBeInTheDocument(); + }); + }); + + describe('view execution sagas', () => { + it('loads sagas for specific execution', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const executionSagas = [createMockSaga({ execution_id: 'exec-target' })]; + mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet.mockResolvedValue({ + data: { sagas: executionSagas, total: 1 }, + error: null, + }); + await renderWithSagas([createMockSaga({ execution_id: 'exec-target' })]); + + const execButtons = screen.getAllByText(/execution/i); + const clickableButton = execButtons.find(el => el.tagName === 'BUTTON'); + if (clickableButton) { + await user.click(clickableButton); + await waitFor(() => { + expect(mocks.getExecutionSagasApiV1SagasExecutionExecutionIdGet).toHaveBeenCalled(); + }); + } + }); + }); + + describe('pagination', () => { + it('shows pagination when items exist', async () => { + const sagas = createMockSagas(5); + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 25 }, + error: null, + }); + + render(AdminSagas); + await tick(); + await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); + + expect(screen.getByText(/showing/i)).toBeInTheDocument(); + }); + + it('changes page size', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const sagas = createMockSagas(5); + mocks.listSagasApiV1SagasGet.mockResolvedValue({ + data: { sagas, total: 25 }, + error: null, + }); + + render(AdminSagas); + await tick(); + await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); + + vi.clearAllMocks(); + const pageSizeSelect = screen.getByDisplayValue('10 / page'); + await user.selectOptions(pageSizeSelect, '25'); + + await waitFor(() => { + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ limit: 25 }) + }) + ); + }); + }); + }); + + describe('refresh rate control', () => { + it('changes refresh rate', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await renderWithSagas(); + + const rateSelect = screen.getByLabelText(/every/i); + await user.selectOptions(rateSelect, '10'); + + vi.clearAllMocks(); + await vi.advanceTimersByTimeAsync(10000); + + expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalledTimes(1); + }); + }); +}); From 438e409e40ee0afac534e89165d29728429fafff Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 26 Dec 2025 03:08:04 +0100 Subject: [PATCH 17/17] removing duplication + misc fixes --- .../admin/events/EventFilters.svelte | 1 - .../admin/users/DeleteUserModal.svelte | 2 +- .../admin/events/__tests__/eventTypes.test.ts | 269 ++++++------------ .../admin/users/__tests__/rateLimits.test.ts | 207 +++++--------- frontend/src/lib/admin/users/rateLimits.ts | 3 +- frontend/src/routes/admin/AdminSagas.svelte | 11 +- frontend/src/routes/admin/AdminUsers.svelte | 2 +- .../admin/__tests__/AdminEvents.test.ts | 95 +++---- .../routes/admin/__tests__/AdminSagas.test.ts | 111 +++----- .../routes/admin/__tests__/AdminUsers.test.ts | 138 ++++----- .../src/routes/admin/__tests__/test-utils.ts | 44 +++ 11 files changed, 365 insertions(+), 518 deletions(-) create mode 100644 frontend/src/routes/admin/__tests__/test-utils.ts diff --git a/frontend/src/components/admin/events/EventFilters.svelte b/frontend/src/components/admin/events/EventFilters.svelte index 100a5d6..0ec4443 100644 --- a/frontend/src/components/admin/events/EventFilters.svelte +++ b/frontend/src/components/admin/events/EventFilters.svelte @@ -1,5 +1,4 @@