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/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/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/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..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())}")
- 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}")
+ if self.lang_version not in lang_info.versions:
+ raise ValueError(f"Version '{self.lang_version}' not supported for {self.lang}. "
+ f"Supported: {lang_info.versions}")
return self
@@ -108,6 +107,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 +122,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/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/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/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)
diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json
index 2e24358..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": [
@@ -2782,7 +2615,172 @@
"description": "Successful Response",
"content": {
"application/json": {
- "schema": {}
+ "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": "string",
+ "title": "Session Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "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": {
@@ -4991,7 +5008,7 @@
"theme": {
"type": "string",
"title": "Theme",
- "default": "one-dark"
+ "default": "auto"
},
"font_size": {
"type": "integer",
@@ -5017,31 +5034,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",
@@ -6388,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": {
@@ -6949,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": {
@@ -6988,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": {
@@ -7580,10 +7714,7 @@
},
"supported_runtimes": {
"additionalProperties": {
- "items": {
- "type": "string"
- },
- "type": "array"
+ "$ref": "#/components/schemas/LanguageInfo"
},
"type": "object",
"title": "Supported Runtimes"
@@ -7791,8 +7922,7 @@
"description": "Maximum connections allowed per user"
},
"shutdown": {
- "type": "object",
- "title": "Shutdown",
+ "$ref": "#/components/schemas/ShutdownStatusResponse",
"description": "Shutdown status information"
},
"timestamp": {
@@ -8281,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": [
@@ -8601,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..9cadc2d 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",
@@ -46,6 +49,7 @@
"@babel/runtime": "^7.24.7",
"@hey-api/openapi-ts": "0.89.2",
"@playwright/test": "^1.52.0",
+ "@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.11",
@@ -208,6 +212,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 +263,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 +1007,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 +1025,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",
@@ -1046,6 +1104,23 @@
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
+ "node_modules/@rollup/plugin-alias": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz",
+ "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==",
+ "dev": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "rollup": ">=4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/plugin-commonjs": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 906e875..d7f70d7 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",
@@ -53,6 +56,7 @@
"@babel/runtime": "^7.24.7",
"@hey-api/openapi-ts": "0.89.2",
"@playwright/test": "^1.52.0",
+ "@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.11",
diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js
index 8cf1e14..818837e 100644
--- a/frontend/rollup.config.js
+++ b/frontend/rollup.config.js
@@ -6,12 +6,26 @@ import postcss from 'rollup-plugin-postcss';
import sveltePreprocess from 'svelte-preprocess';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
+import alias from '@rollup/plugin-alias';
import dotenv from 'dotenv';
import fs from 'fs';
import https from 'https';
import path from 'path';
import json from '@rollup/plugin-json';
+// Path aliases - must match tsconfig.json paths
+const projectRoot = path.resolve('.');
+const aliases = alias({
+ entries: [
+ { find: '$lib', replacement: path.resolve(projectRoot, 'src/lib') },
+ { find: '$components', replacement: path.resolve(projectRoot, 'src/components') },
+ { find: '$stores', replacement: path.resolve(projectRoot, 'src/stores') },
+ { find: '$routes', replacement: path.resolve(projectRoot, 'src/routes') },
+ { find: '$utils', replacement: path.resolve(projectRoot, 'src/utils') },
+ { find: '$styles', replacement: path.resolve(projectRoot, 'src/styles') }
+ ]
+});
+
dotenv.config();
const production = !process.env.ROLLUP_WATCH;
@@ -144,6 +158,8 @@ export default {
'@codemirror/language',
'@codemirror/autocomplete',
'@codemirror/lang-python',
+ '@codemirror/lang-javascript',
+ '@codemirror/lang-go',
'@codemirror/theme-one-dark',
'@uiw/codemirror-theme-github'
]
@@ -155,6 +171,7 @@ export default {
}
},
plugins: [
+ aliases,
replace({
'process.env.VITE_BACKEND_URL': JSON.stringify(''),
preventAssignment: true
@@ -182,7 +199,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/App.svelte b/frontend/src/App.svelte
index 12cfc63..956d912 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,29 +1,29 @@
@@ -53,21 +51,17 @@
© {new Date().getFullYear()} Integr8sCode. Built by Max Azatian. All rights reserved.
diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte
index 04c90fa..3682e2f 100644
--- a/frontend/src/components/Header.svelte
+++ b/frontend/src/components/Header.svelte
@@ -1,28 +1,17 @@
@@ -218,7 +220,7 @@
class="btn btn-ghost btn-icon relative"
aria-label="Notifications"
>
- {@html bellIcon}
+
{#if $unreadCount > 0}
{$unreadCount > 9 ? '9+' : $unreadCount}
@@ -243,10 +245,19 @@
{/if}
+ {#if notificationPermission === 'default'}
+
+
+ Enable desktop notifications
+
+ {/if}
- {#if loading}
+ {#if $loading}
@@ -256,6 +267,7 @@
{:else}
{#each $notifications as notification}
+ {@const NotifIcon = getNotificationIcon(notification.tags)}
- {@html getNotificationIcon(notification.tags)}
+
diff --git a/frontend/src/components/ProtectedRoute.svelte b/frontend/src/components/ProtectedRoute.svelte
index 8c32ad2..fe7f0cc 100644
--- a/frontend/src/components/ProtectedRoute.svelte
+++ b/frontend/src/components/ProtectedRoute.svelte
@@ -1,9 +1,9 @@
+
+{#if variant === 'mobile'}
+
+ {#each actions as action}
+ {@const IconComponent = action.icon}
+
+
+ {action.label}
+
+ {/each}
+
+{:else if variant === 'with-text'}
+
+ {#each actions as action}
+ {@const IconComponent = action.icon}
+
+
+ {action.label}
+
+ {/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..1227374
--- /dev/null
+++ b/frontend/src/components/admin/AutoRefreshControl.svelte
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ Auto-refresh
+
+
+ {#if enabled}
+
+ Every:
+
+ {#each rateOptions as option}
+ {option.label}
+ {/each}
+
+
+ {/if}
+
+
+ {#if loading}
+ Refreshing...
+ {:else}
+ Refresh Now
+ {/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}
+
+
+
+
+ Filters
+ {#if hasActiveFilters}
+
+ {activeFilterCount}
+
+ {/if}
+
+{/if}
+
+{#if open}
+
+
+
+
+ {title}
+
+
+ {#if onClear}
+
+ Clear All
+
+ {/if}
+ {#if onApply}
+
+ Apply
+
+ {/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..36827a5
--- /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}
+ onViewRelated(related.event_id)}
+ class="flex justify-between items-center w-full p-2 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
+ >
+
+ {related.event_type}
+
+
+ {formatTimestamp(related.timestamp)}
+
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
+
+ {#snippet footer()}
+ event && onReplay(event.event.event_id)}
+ class="btn btn-primary"
+ >
+ Replay Event
+
+
+ Close
+
+ {/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..fa5a4e8
--- /dev/null
+++ b/frontend/src/components/admin/events/EventFilters.svelte
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+ Filter Events
+
+
+
+ Clear All
+
+
+ Apply
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/events/EventStatsCards.svelte b/frontend/src/components/admin/events/EventStatsCards.svelte
new file mode 100644
index 0000000..c269b0f
--- /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..92e8969
--- /dev/null
+++ b/frontend/src/components/admin/events/EventsTable.svelte
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
+ Time
+ Type
+ User
+ Service
+ Actions
+
+
+
+ {#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"
+ >
+
+
+ {new Date(event.timestamp).toLocaleDateString()}
+
+
+ {new Date(event.timestamp).toLocaleTimeString()}
+
+
+
+
+
+
+
+
+
+
{event.event_type}
+
+ {event.event_id.slice(0, 8)}...
+
+
+
+
+
+
+ {#if event.metadata?.user_id}
+ { e.stopPropagation(); onViewUser(event.metadata.user_id); }}
+ >
+
+ {event.metadata.user_id}
+
+
+ {:else}
+ -
+ {/if}
+
+
+
+ {event.metadata?.service_name || '-'}
+
+
+
+
+
{ e.stopPropagation(); onPreviewReplay(event.event_id); }}
+ class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded"
+ title="Preview replay"
+ >
+
+
+
{ e.stopPropagation(); onReplay(event.event_id); }}
+ class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded text-blue-600 dark:text-blue-400"
+ title="Replay"
+ >
+
+
+
{ e.stopPropagation(); onDelete(event.event_id); }}
+ class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded text-red-600 dark:text-red-400"
+ title="Delete"
+ >
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+ {#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"
+ >
+
+
+
+
+
+
+
+
+
{event.event_type}
+
+ {event.event_id.slice(0, 8)}...
+
+
+
+
+
+ {formatTimestamp(event.timestamp)}
+
+
+
+
+
{ e.stopPropagation(); onPreviewReplay(event.event_id); }}
+ class="btn btn-ghost btn-xs p-1"
+ title="Preview replay"
+ >
+
+
+
{ e.stopPropagation(); onReplay(event.event_id); }}
+ class="btn btn-ghost btn-xs p-1 text-blue-600 dark:text-blue-400"
+ title="Replay"
+ >
+
+
+
{ e.stopPropagation(); onDelete(event.event_id); }}
+ class="btn btn-ghost btn-xs p-1 text-red-600 dark:text-red-400"
+ title="Delete"
+ >
+
+
+
+
+
+
+ User:
+ {#if event.metadata?.user_id}
+ { e.stopPropagation(); onViewUser(event.metadata.user_id); }}
+ >
+ {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..60ea945
--- /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()}
+
+ Proceed with Replay
+
+
+ Cancel
+
+ {/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..f662b5c
--- /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..ad66f3c
--- /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..b8eb559
--- /dev/null
+++ b/frontend/src/components/admin/events/index.ts
@@ -0,0 +1,7 @@
+export { default as EventStatsCards } from '$components/admin/events/EventStatsCards.svelte';
+export { default as EventFilters } from '$components/admin/events/EventFilters.svelte';
+export { default as EventsTable } from '$components/admin/events/EventsTable.svelte';
+export { default as EventDetailsModal } from '$components/admin/events/EventDetailsModal.svelte';
+export { default as ReplayPreviewModal } from '$components/admin/events/ReplayPreviewModal.svelte';
+export { default as ReplayProgressBanner } from '$components/admin/events/ReplayProgressBanner.svelte';
+export { default as UserOverviewModal } from '$components/admin/events/UserOverviewModal.svelte';
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
new file mode 100644
index 0000000..281f6cc
--- /dev/null
+++ b/frontend/src/components/admin/index.ts
@@ -0,0 +1,6 @@
+// Shared admin components
+export { default as AutoRefreshControl } from '$components/admin/AutoRefreshControl.svelte';
+export { default as StatsCard } from '$components/admin/StatsCard.svelte';
+export { default as StatusBadge } from '$components/admin/StatusBadge.svelte';
+export { default as FilterPanel } from '$components/admin/FilterPanel.svelte';
+export { default as ActionButtons } from '$components/admin/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..a5f6dfd
--- /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
+
+
+ {saga.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}
+
+ {/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..0bd22c4
--- /dev/null
+++ b/frontend/src/components/admin/sagas/SagaFilters.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+ Search
+
+
+
+ State
+
+ All States
+ {#each Object.entries(SAGA_STATES) as [value, state]}
+ {state.label}
+ {/each}
+
+
+
+ Execution ID
+
+
+
+ Actions
+
+ Clear Filters
+
+
+
diff --git a/frontend/src/components/admin/sagas/SagaStatsCards.svelte b/frontend/src/components/admin/sagas/SagaStatsCards.svelte
new file mode 100644
index 0000000..4115551
--- /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..e5a9dd4
--- /dev/null
+++ b/frontend/src/components/admin/sagas/SagasTable.svelte
@@ -0,0 +1,144 @@
+
+
+
+ {#if loading && sagas.length === 0}
+
+ {: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)}%
+
+
+
+
+ onViewExecution(saga.execution_id)}
+ class="flex-1 btn btn-sm btn-secondary-outline"
+ >
+ Execution
+
+ onViewDetails(saga.saga_id)}
+ class="flex-1 btn btn-sm btn-primary"
+ >
+ View Details
+
+
+
+ {/each}
+
+
+
+
+
+
+
+ {#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, 8)}...
+ onViewExecution(saga.execution_id)}
+ class="text-xs text-primary hover:text-primary-dark"
+ >
+ Execution: {saga.execution_id.slice(0, 8)}...
+
+
+
+ {stateInfo.label}
+ {#if saga.retry_count > 0}
+ (Retry: {saga.retry_count})
+ {/if}
+
+
+
+
+
+
{saga.completed_steps.length}
+
+ {#if saga.current_step}
+
Current: {saga.current_step}
+ {/if}
+
+
+ {formatTimestamp(saga.created_at)}
+
+ {formatDurationBetween(saga.created_at, saga.completed_at || saga.updated_at)}
+
+
+ onViewDetails(saga.saga_id)}
+ class="text-primary hover:text-primary-dark"
+ >
+ View Details
+
+
+
+ {/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..cc28de8
--- /dev/null
+++ b/frontend/src/components/admin/sagas/index.ts
@@ -0,0 +1,4 @@
+export { default as SagaStatsCards } from '$components/admin/sagas/SagaStatsCards.svelte';
+export { default as SagaFilters } from '$components/admin/sagas/SagaFilters.svelte';
+export { default as SagasTable } from '$components/admin/sagas/SagasTable.svelte';
+export { default as SagaDetailsModal } from '$components/admin/sagas/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..659c178
--- /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} ?
+
+
+
+
+
+ Delete all user data (executions, scripts, etc.)
+
+
+
+ {#if cascadeDelete}
+
+ Warning: This will permanently delete all data associated with this user.
+
+ {/if}
+
+ Cancel
+
+ {#if deleting}
+ Deleting...
+ {:else}
+ Delete 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..ae056eb
--- /dev/null
+++ b/frontend/src/components/admin/users/RateLimitsModal.svelte
@@ -0,0 +1,261 @@
+
+
+
+ {#snippet children()}
+ {#if loading}
+
+ {:else if config}
+
+
+
+
+
Quick Settings
+
+
+ Bypass all rate limits
+
+
+
+
+
+ Global Multiplier
+
+
+
+ Multiplies all limits (1.0 = default, 2.0 = double)
+
+
+
+
+ Admin Notes
+
+
+
+
+
+
+
+
+
+
Endpoint Rate Limits
+
+ Add Rule
+
+
+
+
+
+
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}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if usage && Object.keys(usage).length > 0}
+
+
+
Current Usage
+ Reset All Counters
+
+
+ {#each Object.entries(usage) as [endpoint, usageData]}
+
+ {endpoint}
+
+ {usageData.count || usageData.tokens_remaining || 0}
+
+
+ {/each}
+
+
+ {/if}
+
+
+ Cancel
+
+ {#if saving}
+ Saving...
+ {:else}
+ Save Changes
+ {/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 @@
+
+
+
+
+
+
+ Search
+
+
+
+ Role
+
+ All Roles
+ User
+ Admin
+
+
+
+ Status
+
+ All Status
+ Active
+ Disabled
+
+
+
showAdvancedFilters = !showAdvancedFilters}
+ class="btn btn-outline flex items-center gap-2 w-full sm:w-auto justify-center"
+ >
+
+ Advanced
+
+
+ Reset
+
+
+
+ {#if showAdvancedFilters}
+
+
Rate Limit Filters
+
+
+ Bypass Rate Limit
+
+ All
+ Yes (Bypassed)
+ No (Limited)
+
+
+
+ Custom Limits
+
+ All
+ Has Custom
+ Default Only
+
+
+
+ Global Multiplier
+
+ All
+ Custom (≠ 1.0)
+ Default (= 1.0)
+
+
+
+
+ {/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..98cb6de
--- /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..25fd37f
--- /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)}
+
+
+
onEdit(user)}
+ class="flex-1 btn btn-sm btn-outline flex items-center justify-center gap-1"
+ >
+ Edit
+
+
onRateLimits(user)}
+ class="flex-1 btn btn-sm btn-outline flex items-center justify-center gap-1"
+ >
+ Limits
+
+
onDelete(user)}
+ class="btn btn-sm btn-danger flex items-center justify-center gap-1"
+ >
+ Delete
+
+
+
+ {/each}
+
+
+
+
+
+
+
+ {#each users as user}
+
+ {user.username}
+ {user.email || '-'}
+
+ {user.role}
+
+ {formatTimestamp(user.created_at)}
+
+
+ {user.is_active ? 'Active' : 'Inactive'}
+
+
+
+
+
onEdit(user)}
+ class="text-green-600 hover:text-green-800 dark:text-green-400"
+ title="Edit User"
+ >
+
+
+
onRateLimits(user)}
+ class="text-blue-600 hover:text-blue-800 dark:text-blue-400"
+ title="Manage Rate Limits"
+ >
+
+
+
onDelete(user)}
+ class="text-red-600 hover:text-red-800 dark:text-red-400"
+ title="Delete User"
+ >
+
+
+
+
+
+ {/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..78dca19
--- /dev/null
+++ b/frontend/src/components/admin/users/index.ts
@@ -0,0 +1,5 @@
+export { default as UserFilters } from '$components/admin/users/UserFilters.svelte';
+export { default as UsersTable } from '$components/admin/users/UsersTable.svelte';
+export { default as UserFormModal } from '$components/admin/users/UserFormModal.svelte';
+export { default as DeleteUserModal } from '$components/admin/users/DeleteUserModal.svelte';
+export { default as RateLimitsModal } from '$components/admin/users/RateLimitsModal.svelte';
diff --git a/frontend/src/components/editor/CodeMirrorEditor.svelte b/frontend/src/components/editor/CodeMirrorEditor.svelte
new file mode 100644
index 0000000..4a14b00
--- /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 @@
+
+
+
diff --git a/frontend/src/components/editor/LanguageSelect.svelte b/frontend/src/components/editor/LanguageSelect.svelte
new file mode 100644
index 0000000..870a294
--- /dev/null
+++ b/frontend/src/components/editor/LanguageSelect.svelte
@@ -0,0 +1,159 @@
+
+
+
+
{ showOptions = !showOptions; if (showOptions) focusedLangIndex = 0; }}
+ onkeydown={handleTriggerKeydown}
+ disabled={!available}
+ aria-haspopup="menu"
+ aria-expanded={showOptions}
+ class="btn btn-secondary-outline btn-sm w-36 flex items-center justify-between text-left"
+ class:opacity-50={!available}
+ class:cursor-not-allowed={!available}>
+ {available ? `${lang} ${version}` : "Unavailable"}
+
+
+
+
+
+ {#if showOptions && available}
+
+
{ hoveredLang = null; focusedVersionIndex = -1; }}>
+ {#each Object.entries(runtimes) as [l, info], i (l)}
+ { hoveredLang = l; focusedLangIndex = i; focusedVersionIndex = -1; }}>
+ { focusedLangIndex = i; }}
+ class="flex justify-between items-center w-full px-3 py-2 text-sm text-fg-default dark:text-dark-fg-default text-left"
+ class:bg-neutral-100={focusedLangIndex === i}
+ class:dark:bg-neutral-700={focusedLangIndex === i}>
+ {l}
+
+
+
+ {#if hoveredLang === l && info.versions.length > 0}
+
+ {/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..404521b
--- /dev/null
+++ b/frontend/src/components/editor/OutputPanel.svelte
@@ -0,0 +1,172 @@
+
+
+
+
+ Execution Output
+
+
+ {#if phase !== 'idle'}
+
+ {:else if error && !result}
+
+
+
Execution Failed
+
{error}
+
+ {:else if result}
+
+
+
+ Status: {result.status}
+
+
+ {#if result.execution_id}
+
+
copyToClipboard(result!.execution_id, 'Execution ID')}>
+
+
+
+ Execution ID: {result.execution_id}Click to copy
+
+
+ {/if}
+
+
+ {#if result.stdout}
+
+
Output:
+
+
{@html sanitize(ansiConverter.toHtml(result.stdout || ''))}
+
+
copyToClipboard(result!.stdout!, 'Output')}>
+
+
+
+ Copy output
+
+
+
+
+ {/if}
+
+ {#if result.stderr}
+
+
Errors:
+
+
+
{@html sanitize(ansiConverter.toHtml(result.stderr || ''))}
+
+
+
copyToClipboard(result!.stderr!, 'Error text')}>
+
+
+
+ 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..843fc53
--- /dev/null
+++ b/frontend/src/components/editor/ResourceLimits.svelte
@@ -0,0 +1,45 @@
+
+
+{#if limits}
+
+
show = !show} aria-expanded={show}>
+
+ Resource Limits
+ {#if show} {:else} {/if}
+
+ {#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
+
+
+
+ {show ? "Hide" : "Show"} Saved Scripts
+
+
+ {#if show}
+
+ {#if scripts.length > 0}
+
+
+ {#each scripts as item (item.id)}
+
+ onload(item)}
+ title={`Load ${item.name} (${item.lang || 'python'} ${item.lang_version || '3.11'})`}>
+
+ {item.name}
+
+ {item.lang || 'python'} {item.lang_version || '3.11'}
+
+
+
+ { e.stopPropagation(); ondelete(item.id); }}
+ title={`Delete ${item.name}`}>
+ Delete
+
+
+
+ {/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
+
+
+ New
+
+
+ Upload
+
+ {#if authenticated}
+
+ Save
+
+ {/if}
+
+ Export
+
+
+
diff --git a/frontend/src/components/editor/index.ts b/frontend/src/components/editor/index.ts
new file mode 100644
index 0000000..f8a6116
--- /dev/null
+++ b/frontend/src/components/editor/index.ts
@@ -0,0 +1,7 @@
+export { default as CodeMirrorEditor } from '$components/editor/CodeMirrorEditor.svelte';
+export { default as OutputPanel } from '$components/editor/OutputPanel.svelte';
+export { default as LanguageSelect } from '$components/editor/LanguageSelect.svelte';
+export { default as ResourceLimits } from '$components/editor/ResourceLimits.svelte';
+export { default as EditorToolbar } from '$components/editor/EditorToolbar.svelte';
+export { default as ScriptActions } from '$components/editor/ScriptActions.svelte';
+export { default as SavedScripts } from '$components/editor/SavedScripts.svelte';
diff --git a/frontend/src/lib/__mocks__/api-interceptors.ts b/frontend/src/lib/__mocks__/api-interceptors.ts
index 7940676..edfb04c 100644
--- a/frontend/src/lib/__mocks__/api-interceptors.ts
+++ b/frontend/src/lib/__mocks__/api-interceptors.ts
@@ -4,4 +4,4 @@ import { vi } from 'vitest';
export const initializeApiInterceptors = vi.fn();
// Re-export real pure functions - no need to mock these
-export { getErrorMessage, unwrap, unwrapOr } from '../api-interceptors';
+export { getErrorMessage, unwrap, unwrapOr } from '$lib/api-interceptors';
diff --git a/frontend/src/lib/__tests__/auth-init.test.ts b/frontend/src/lib/__tests__/auth-init.test.ts
index 86b7ef9..6bc5ea6 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
@@ -105,7 +105,7 @@ describe('auth-init', () => {
it('returns false when no persisted auth and verification fails', async () => {
mockVerifyAuth.mockResolvedValue(false);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
expect(result).toBe(false);
@@ -114,7 +114,7 @@ describe('auth-init', () => {
it('returns true when no persisted auth but verification succeeds', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
expect(result).toBe(true);
@@ -126,7 +126,7 @@ describe('auth-init', () => {
it('only initializes once', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
await AuthInitializer.initialize();
await AuthInitializer.initialize();
await AuthInitializer.initialize();
@@ -139,7 +139,7 @@ describe('auth-init', () => {
new Promise(resolve => setTimeout(() => resolve(true), 100))
);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
const results = await Promise.all([
AuthInitializer.initialize(),
@@ -163,10 +163,10 @@ 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');
+ const { AuthInitializer } = await import('$lib/auth-init');
await AuthInitializer.initialize();
expect(mockIsAuthenticatedSet).toHaveBeenCalledWith(true);
@@ -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');
+ const { AuthInitializer } = await import('$lib/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,10 +207,10 @@ 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');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
expect(result).toBe(true); // Should keep auth state
@@ -241,10 +226,10 @@ 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');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
expect(result).toBe(false);
@@ -254,7 +239,7 @@ describe('auth-init', () => {
describe('isAuthenticated static method', () => {
it('returns false before initialization', async () => {
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
expect(AuthInitializer.isAuthenticated()).toBe(false);
});
@@ -263,7 +248,7 @@ describe('auth-init', () => {
mockVerifyAuth.mockResolvedValue(true);
mockIsAuthenticatedValue = true;
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
await AuthInitializer.initialize();
expect(AuthInitializer.isAuthenticated()).toBe(true);
@@ -274,7 +259,7 @@ describe('auth-init', () => {
it('returns immediately if already initialized', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
await AuthInitializer.initialize();
const result = await AuthInitializer.waitForInit();
@@ -288,7 +273,7 @@ describe('auth-init', () => {
new Promise(resolve => { resolveVerify = resolve; })
);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
// Start initialization
const initPromise = AuthInitializer.initialize();
@@ -308,7 +293,7 @@ describe('auth-init', () => {
it('initializes if not started', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.waitForInit();
@@ -321,7 +306,7 @@ describe('auth-init', () => {
it('initializeAuth calls AuthInitializer.initialize', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { initializeAuth } = await import('../auth-init');
+ const { initializeAuth } = await import('$lib/auth-init');
const result = await initializeAuth();
expect(result).toBe(true);
@@ -330,7 +315,7 @@ describe('auth-init', () => {
it('waitForAuth calls AuthInitializer.waitForInit', async () => {
mockVerifyAuth.mockResolvedValue(true);
- const { waitForAuth } = await import('../auth-init');
+ const { waitForAuth } = await import('$lib/auth-init');
const result = await waitForAuth();
expect(result).toBe(true);
@@ -340,7 +325,7 @@ describe('auth-init', () => {
mockVerifyAuth.mockResolvedValue(true);
mockIsAuthenticatedValue = true;
- const { initializeAuth, checkAuth } = await import('../auth-init');
+ const { initializeAuth, checkAuth } = await import('$lib/auth-init');
await initializeAuth();
expect(checkAuth()).toBe(true);
@@ -348,11 +333,11 @@ 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');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
expect(result).toBe(false);
@@ -362,7 +347,7 @@ describe('auth-init', () => {
mockVerifyAuth.mockResolvedValue(true);
mockLoadUserSettings.mockRejectedValue(new Error('Settings error'));
- const { AuthInitializer } = await import('../auth-init');
+ const { AuthInitializer } = await import('$lib/auth-init');
const result = await AuthInitializer.initialize();
// Should still return true even if settings fail
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..3df8749 100644
--- a/frontend/src/lib/__tests__/user-settings.test.ts
+++ b/frontend/src/lib/__tests__/user-settings.test.ts
@@ -1,35 +1,25 @@
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();
-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),
}));
-// Mock the theme store
-const mockSetThemeLocal = vi.fn();
+const mockSetTheme = vi.fn();
vi.mock('../../stores/theme', () => ({
- setThemeLocal: (theme: string) => mockSetThemeLocal(theme),
+ setTheme: (theme: string) => mockSetTheme(theme),
}));
-// Mock the auth store
let mockIsAuthenticated = true;
vi.mock('../../stores/auth', () => ({
@@ -43,24 +33,17 @@ vi.mock('../../stores/auth', () => ({
describe('user-settings', () => {
beforeEach(async () => {
- // Reset all mocks
mockGetUserSettings.mockReset();
- mockUpdateTheme.mockReset();
- mockUpdateEditorSettings.mockReset();
- mockGetCachedSettings.mockReset();
- mockSetCachedSettings.mockReset();
- mockUpdateCachedSetting.mockReset();
- mockSetThemeLocal.mockReset();
-
- // Default to authenticated
+ mockUpdateUserSettings.mockReset();
+ mockSetUserSettings.mockReset();
+ mockSetTheme.mockReset();
+
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,218 +51,177 @@ 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
});
- const { loadUserSettings } = await import('../user-settings');
+ const { loadUserSettings } = await import('$lib/user-settings');
await loadUserSettings();
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');
+ const { loadUserSettings } = await import('$lib/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
});
- const { loadUserSettings } = await import('../user-settings');
+ const { loadUserSettings } = await import('$lib/user-settings');
await loadUserSettings();
- expect(mockSetThemeLocal).toHaveBeenCalledWith('dark');
+ expect(mockSetTheme).toHaveBeenCalledWith('dark');
});
it('returns undefined on API error', async () => {
- mockGetCachedSettings.mockReturnValue(null);
mockGetUserSettings.mockResolvedValue({
data: null,
error: { detail: 'Not found' }
});
- const { loadUserSettings } = await import('../user-settings');
+ const { loadUserSettings } = await import('$lib/user-settings');
const result = await loadUserSettings();
expect(result).toBeUndefined();
});
it('returns undefined on network error', async () => {
- mockGetCachedSettings.mockReturnValue(null);
mockGetUserSettings.mockRejectedValue(new Error('Network error'));
- const { loadUserSettings } = await import('../user-settings');
+ const { loadUserSettings } = await import('$lib/user-settings');
const result = await loadUserSettings();
expect(result).toBeUndefined();
});
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');
+ const { loadUserSettings } = await import('$lib/user-settings');
await loadUserSettings();
- expect(mockSetThemeLocal).not.toHaveBeenCalled();
+ expect(mockSetTheme).not.toHaveBeenCalled();
});
});
- 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('$lib/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('$lib/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 { saveUserSettings } = await import('$lib/user-settings');
+ await saveUserSettings({ 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('$lib/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 () => {
+ const responseData = { user_id: '123', theme: 'system' };
+ mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null });
+
+ const { saveUserSettings } = await import('$lib/user-settings');
+ await saveUserSettings({ theme: 'system' });
+
+ expect(mockSetUserSettings).toHaveBeenCalledWith(responseData);
+ });
+
+ it('applies theme locally when theme is saved', async () => {
+ const responseData = { user_id: '123', theme: 'dark' };
+ mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null });
+
+ const { saveUserSettings } = await import('$lib/user-settings');
+ await saveUserSettings({ theme: 'dark' });
+
+ expect(mockSetTheme).toHaveBeenCalledWith('dark');
+ });
+
+ it('does not apply theme when only editor settings saved', async () => {
+ const responseData = { user_id: '123', editor: { font_size: 16 } };
+ mockUpdateUserSettings.mockResolvedValue({ data: responseData, error: null });
- const { saveEditorSettings } = await import('../user-settings');
- await saveEditorSettings(editorSettings as any);
+ const { saveUserSettings } = await import('$lib/user-settings');
+ await saveUserSettings({ editor: { font_size: 16 } });
- expect(mockUpdateCachedSetting).toHaveBeenCalledWith('editor', editorSettings);
+ expect(mockSetTheme).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('$lib/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('$lib/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('$lib/user-settings');
+ const result = await saveUserSettings({ theme: 'dark' });
expect(result).toBe(false);
});
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..aaf0653
--- /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 '$lib/admin/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..fc76dc6
--- /dev/null
+++ b/frontend/src/lib/admin/events/__tests__/eventTypes.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect } from 'vitest';
+import {
+ EVENT_TYPES,
+ getEventTypeColor,
+ getEventTypeLabel,
+ createDefaultEventFilters,
+ hasActiveFilters,
+ getActiveFilterCount,
+ getActiveFilterSummary,
+ type EventFilters
+} from '$lib/admin/events/eventTypes';
+
+const withFilter = (override: Partial): EventFilters =>
+ ({ ...createDefaultEventFilters(), ...override });
+
+describe('eventTypes', () => {
+ describe('EVENT_TYPES', () => {
+ const expectedEvents = [
+ 'execution.requested', 'execution.started', 'execution.completed', 'execution.failed', 'execution.timeout',
+ 'pod.created', 'pod.running', 'pod.succeeded', 'pod.failed', 'pod.terminated'
+ ];
+
+ it('contains all expected events', () => {
+ expectedEvents.forEach(e => expect(EVENT_TYPES).toContain(e));
+ });
+ });
+
+ describe('getEventTypeColor', () => {
+ it.each([
+ ['execution.completed', 'text-green'],
+ ['pod.succeeded', 'text-green'],
+ ['execution.failed', 'text-red'],
+ ['execution.timeout', 'text-red'],
+ ['pod.failed', 'text-red'],
+ ['execution.started', 'text-blue'],
+ ['pod.running', 'text-blue'],
+ ['execution.requested', 'text-purple'],
+ ['pod.created', 'text-indigo'],
+ ['pod.terminated', 'text-orange'],
+ ['unknown.event', 'text-neutral'],
+ ])('%s returns %s', (eventType, expectedColor) => {
+ const color = getEventTypeColor(eventType);
+ expect(color).toContain(expectedColor);
+ expect(color).toContain('dark:');
+ });
+ });
+
+ describe('getEventTypeLabel', () => {
+ it.each([
+ ['execution.requested', ''],
+ ['execution.completed', 'execution.completed'],
+ ['pod.running', 'pod.running'],
+ ['single', 'single'],
+ ['a.b.c', 'a.b.c'],
+ ])('%s returns %s', (input, expected) => {
+ expect(getEventTypeLabel(input)).toBe(expected);
+ });
+ });
+
+ describe('createDefaultEventFilters', () => {
+ it('returns object with all empty values', () => {
+ expect(createDefaultEventFilters()).toEqual({
+ event_types: [],
+ aggregate_id: '',
+ correlation_id: '',
+ user_id: '',
+ service_name: '',
+ search_text: '',
+ start_time: '',
+ end_time: ''
+ });
+ });
+
+ it('returns new object each time', () => {
+ expect(createDefaultEventFilters()).not.toBe(createDefaultEventFilters());
+ });
+ });
+
+ describe('hasActiveFilters', () => {
+ it('returns false for empty filters', () => {
+ expect(hasActiveFilters(createDefaultEventFilters())).toBe(false);
+ });
+
+ it.each([
+ ['event_types', { event_types: ['execution.completed'] }],
+ ['search_text', { search_text: 'test' }],
+ ['correlation_id', { correlation_id: 'abc' }],
+ ['aggregate_id', { aggregate_id: 'exec-1' }],
+ ['user_id', { user_id: 'user-1' }],
+ ['service_name', { service_name: 'svc' }],
+ ['start_time', { start_time: '2024-01-01' }],
+ ['end_time', { end_time: '2024-01-02' }],
+ ])('returns true when %s has value', (_, override) => {
+ expect(hasActiveFilters(withFilter(override as Partial))).toBe(true);
+ });
+ });
+
+ describe('getActiveFilterCount', () => {
+ it.each([
+ [createDefaultEventFilters(), 0],
+ [withFilter({ event_types: ['x'], search_text: 'y', correlation_id: 'z' }), 3],
+ [withFilter({
+ event_types: ['x'], search_text: 'y', correlation_id: 'z',
+ aggregate_id: 'a', user_id: 'u', service_name: 's',
+ start_time: 't1', end_time: 't2'
+ }), 8],
+ ])('returns correct count', (filters, expected) => {
+ expect(getActiveFilterCount(filters)).toBe(expected);
+ });
+ });
+
+ describe('getActiveFilterSummary', () => {
+ it('returns empty array for empty filters', () => {
+ expect(getActiveFilterSummary(createDefaultEventFilters())).toEqual([]);
+ });
+
+ it.each([
+ [{ event_types: ['a', 'b'] }, '2 event types'],
+ [{ event_types: ['a'] }, '1 event type'],
+ [{ search_text: 'test' }, 'search'],
+ [{ correlation_id: 'abc' }, 'correlation'],
+ [{ start_time: '2024-01-01' }, 'time range'],
+ [{ end_time: '2024-01-02' }, 'time range'],
+ ])('includes expected label', (override, expected) => {
+ expect(getActiveFilterSummary(withFilter(override as Partial))).toContain(expected);
+ });
+
+ it('includes all active filter labels', () => {
+ const summary = getActiveFilterSummary(withFilter({
+ event_types: ['x'], search_text: 'y', correlation_id: 'z',
+ aggregate_id: 'a', user_id: 'u', service_name: 's', start_time: 't'
+ }));
+ ['1 event type', 'search', 'correlation', 'aggregate', 'user', 'service', 'time range']
+ .forEach(label => expect(summary).toContain(label));
+ });
+ });
+});
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..86c136b
--- /dev/null
+++ b/frontend/src/lib/admin/events/index.ts
@@ -0,0 +1 @@
+export * from '$lib/admin/events/eventTypes';
diff --git a/frontend/src/lib/admin/index.ts b/frontend/src/lib/admin/index.ts
new file mode 100644
index 0000000..44472b1
--- /dev/null
+++ b/frontend/src/lib/admin/index.ts
@@ -0,0 +1,9 @@
+// Shared admin utilities
+export * from '$lib/admin/pagination.svelte';
+export * from '$lib/admin/autoRefresh.svelte';
+export * from '$lib/admin/constants';
+
+// Domain-specific exports
+export * from '$lib/admin/sagas';
+export * from '$lib/admin/users';
+export * from '$lib/admin/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..1c107e9
--- /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 '$lib/admin/sagas/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..d4dbc36
--- /dev/null
+++ b/frontend/src/lib/admin/sagas/index.ts
@@ -0,0 +1 @@
+export * from '$lib/admin/sagas/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..a58b4a8
--- /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 '$lib/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..2ca1753
--- /dev/null
+++ b/frontend/src/lib/admin/users/__tests__/rateLimits.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest';
+import {
+ GROUP_COLORS,
+ getGroupColor,
+ ENDPOINT_GROUP_PATTERNS,
+ detectGroupFromEndpoint,
+ getDefaultRules,
+ getDefaultRulesWithMultiplier,
+ createEmptyRule
+} from '$lib/admin/users/rateLimits';
+
+const EXPECTED_GROUPS = ['execution', 'admin', 'sse', 'websocket', 'auth', 'api', 'public'];
+const findRuleByGroup = (rules: ReturnType, group: string) =>
+ rules.find(r => r.group === group);
+
+describe('rateLimits', () => {
+ describe('GROUP_COLORS', () => {
+ it('has all expected groups with dark mode', () => {
+ EXPECTED_GROUPS.forEach(group => {
+ expect(GROUP_COLORS[group]).toBeDefined();
+ expect(GROUP_COLORS[group]).toContain('dark:');
+ });
+ });
+ });
+
+ describe('getGroupColor', () => {
+ it.each([
+ ['execution', GROUP_COLORS.execution],
+ ['admin', GROUP_COLORS.admin],
+ ['unknown', GROUP_COLORS.api],
+ ])('returns correct color for %s', (group, expected) => {
+ expect(getGroupColor(group)).toBe(expected);
+ });
+ });
+
+ 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);
+ ['execution', 'admin', 'auth'].forEach(g => expect(groups).toContain(g));
+ });
+ });
+
+ describe('detectGroupFromEndpoint', () => {
+ it.each([
+ ['/api/v1/execute', 'execution'],
+ ['^/api/v1/execute$', 'execution'],
+ ['^/api/v1/execute.*$', 'execution'],
+ ['/admin/users', 'admin'],
+ ['/api/v1/admin/events', 'admin'],
+ ['/events/stream', 'sse'],
+ ['/api/v1/events/123', 'sse'],
+ ['/ws', 'websocket'],
+ ['/api/v1/ws/connect', 'websocket'],
+ ['/auth/login', 'auth'],
+ ['/api/v1/auth/token', 'auth'],
+ ['/health', 'public'],
+ ['/api/health/check', 'public'],
+ ['/api/v1/users', 'api'],
+ ['/some/random/path', 'api'],
+ ])('detects %s as %s', (endpoint, expected) => {
+ expect(detectGroupFromEndpoint(endpoint)).toBe(expected);
+ });
+ });
+
+ describe('getDefaultRules', () => {
+ const rules = getDefaultRules();
+
+ it('returns array of rules with required properties', () => {
+ expect(rules.length).toBeGreaterThan(0);
+ const requiredProps = ['endpoint_pattern', 'group', 'requests', 'window_seconds', 'algorithm', 'priority'];
+ rules.forEach(rule => requiredProps.forEach(prop => expect(rule).toHaveProperty(prop)));
+ });
+
+ it.each([
+ ['execution', 10],
+ ['api', 60],
+ ])('includes %s rule with %d req/min', (group, requests) => {
+ const rule = findRuleByGroup(rules, group);
+ expect(rule).toBeDefined();
+ expect(rule?.requests).toBe(requests);
+ });
+ });
+
+ describe('getDefaultRulesWithMultiplier', () => {
+ it('returns rules with effective_requests', () => {
+ getDefaultRulesWithMultiplier(1.0).forEach(rule => {
+ expect(rule).toHaveProperty('effective_requests');
+ });
+ });
+
+ it.each([
+ [2.0, 20, 120],
+ [0.5, 5, 30],
+ [1.0, 10, 60],
+ [1.5, 15, 90],
+ [1.3, 13, 78],
+ ])('with multiplier %d: execution=%d, api=%d', (multiplier, execExpected, apiExpected) => {
+ const rules = getDefaultRulesWithMultiplier(multiplier);
+ expect(findRuleByGroup(rules, 'execution')?.effective_requests).toBe(execExpected);
+ expect(findRuleByGroup(rules, 'api')?.effective_requests).toBe(apiExpected);
+ });
+
+ it('handles non-positive multipliers as 1.0', () => {
+ [undefined, 0, -1, -0.5].forEach(mult => {
+ const rules = getDefaultRulesWithMultiplier(mult);
+ expect(findRuleByGroup(rules, 'execution')?.effective_requests).toBe(10);
+ });
+ });
+ });
+
+ describe('createEmptyRule', () => {
+ it('returns rule with expected defaults', () => {
+ const rule = createEmptyRule();
+ expect(rule).toEqual({
+ endpoint_pattern: '',
+ group: 'api',
+ requests: 60,
+ window_seconds: 60,
+ burst_multiplier: 1.5,
+ algorithm: 'sliding_window',
+ priority: 0,
+ enabled: true
+ });
+ });
+
+ it('returns new object each time', () => {
+ expect(createEmptyRule()).not.toBe(createEmptyRule());
+ });
+ });
+});
diff --git a/frontend/src/lib/admin/users/index.ts b/frontend/src/lib/admin/users/index.ts
new file mode 100644
index 0000000..b82968e
--- /dev/null
+++ b/frontend/src/lib/admin/users/index.ts
@@ -0,0 +1 @@
+export * from '$lib/admin/users/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..ca2f322
--- /dev/null
+++ b/frontend/src/lib/admin/users/rateLimits.ts
@@ -0,0 +1,77 @@
+/**
+ * Rate limit configurations and utilities for user management
+ */
+import type { RateLimitRule, EndpointGroup } from '$lib/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();
+ // Only positive multipliers are valid; 0 and negative values fall back to 1.0
+ const effectiveMultiplier = multiplier > 0 ? 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/lib/api-interceptors.ts b/frontend/src/lib/api-interceptors.ts
index ab9a347..e400de0 100644
--- a/frontend/src/lib/api-interceptors.ts
+++ b/frontend/src/lib/api-interceptors.ts
@@ -1,5 +1,5 @@
-import { client } from './api/client.gen';
-import { addToast } from '../stores/toastStore';
+import { client } from '$lib/api/client.gen';
+import { addToast } from '$stores/toastStore';
import { goto } from '@mateothegreat/svelte5-router';
import {
isAuthenticated,
@@ -8,22 +8,34 @@ import {
userRole,
userEmail,
csrfToken,
-} from '../stores/auth';
+} from '$stores/auth';
import { get } from 'svelte/store';
-import type { ValidationError } from './api';
+import type { ValidationError } from '$lib/api';
let isHandling401 = false;
const AUTH_ENDPOINTS = ['/api/v1/auth/login', '/api/v1/auth/register', '/api/v1/auth/verify-token'];
+type ToastType = 'error' | 'warning' | 'info' | 'success';
+
+const STATUS_MESSAGES: Record = {
+ 403: { message: 'Access denied.', type: 'error' },
+ 429: { message: 'Too many requests. Please slow down.', type: 'warning' },
+};
+
+function extractDetail(err: unknown): string | ValidationError[] | null {
+ if (typeof err === 'object' && err !== null && 'detail' in err) {
+ return (err as { detail: string | ValidationError[] }).detail;
+ }
+ return null;
+}
+
export function getErrorMessage(err: unknown, fallback = 'An error occurred'): string {
if (!err) return fallback;
- if (typeof err === 'object' && 'detail' in err) {
- const detail = (err as { detail?: ValidationError[] | string }).detail;
- if (typeof detail === 'string') return detail;
- if (Array.isArray(detail) && detail.length > 0) {
- return detail.map((e) => `${e.loc[e.loc.length - 1]}: ${e.msg}`).join(', ');
- }
+ const detail = extractDetail(err);
+ if (typeof detail === 'string') return detail;
+ if (Array.isArray(detail) && detail.length > 0) {
+ return detail.map((e) => `${e.loc[e.loc.length - 1]}: ${e.msg}`).join(', ');
}
if (err instanceof Error) return err.message;
@@ -46,15 +58,59 @@ function clearAuthState(): void {
userRole.set(null);
userEmail.set(null);
csrfToken.set(null);
- localStorage.removeItem('authState');
+ sessionStorage.removeItem('authState');
}
-function handleAuthFailure(currentPath: string): void {
- clearAuthState();
- if (currentPath !== '/login' && currentPath !== '/register') {
- sessionStorage.setItem('redirectAfterLogin', currentPath);
+function handle401(isAuthEndpoint: boolean): void {
+ if (isAuthEndpoint) return;
+
+ const wasAuthenticated = get(isAuthenticated);
+ if (wasAuthenticated && !isHandling401) {
+ isHandling401 = true;
+ const currentPath = window.location.pathname + window.location.search;
+ addToast('Session expired. Please log in again.', 'warning');
+ clearAuthState();
+ if (currentPath !== '/login' && currentPath !== '/register') {
+ sessionStorage.setItem('redirectAfterLogin', currentPath);
+ }
+ goto('/login');
+ setTimeout(() => { isHandling401 = false; }, 1000);
+ } else {
+ clearAuthState();
+ }
+}
+
+function handleErrorStatus(status: number | undefined, error: unknown, isAuthEndpoint: boolean): boolean {
+ if (!status) {
+ if (!isAuthEndpoint) addToast('Network error. Check your connection.', 'error');
+ return true;
+ }
+
+ if (status === 401) {
+ handle401(isAuthEndpoint);
+ return true;
+ }
+
+ const mapped = STATUS_MESSAGES[status];
+ if (mapped) {
+ addToast(mapped.message, mapped.type);
+ return true;
+ }
+
+ if (status === 422) {
+ const detail = extractDetail(error);
+ if (Array.isArray(detail) && detail.length > 0) {
+ addToast(`Validation error:\n${formatValidationErrors(detail)}`, 'error');
+ return true;
+ }
}
- goto('/login');
+
+ if (status >= 500) {
+ addToast('Server error. Please try again later.', 'error');
+ return true;
+ }
+
+ return false;
}
export function initializeApiInterceptors(): void {
@@ -70,47 +126,11 @@ 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);
- }
- return { _handled: true, _status: 401 };
- }
-
- if (status === 403) {
- addToast('Access denied.', 'error');
- return error;
- }
-
- if (status === 422 && typeof error === 'object' && error !== null && 'detail' in error) {
- const detail = (error as { detail: ValidationError[] }).detail;
- if (Array.isArray(detail) && detail.length > 0) {
- addToast(`Validation error:\n${formatValidationErrors(detail)}`, 'error');
- return error;
- }
- }
-
- if (status === 429) {
- addToast('Too many requests. Please slow down.', 'warning');
- return error;
- }
-
- if (status && status >= 500) {
- addToast('Server error. Please try again later.', 'error');
- return error;
- }
-
- if (!response && !url.includes('/verify-token')) {
- addToast('Network error. Check your connection.', 'error');
- return error;
+ const handled = handleErrorStatus(status, error, isAuthEndpoint);
+ if (!handled && !isAuthEndpoint) {
+ addToast(getErrorMessage(error, 'An error occurred'), 'error');
}
- addToast(getErrorMessage(error, 'An error occurred'), 'error');
return error;
});
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 9ad394b..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
*/
@@ -485,26 +503,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;
};
/**
@@ -1295,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
*
@@ -1669,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
*/
@@ -1687,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
*
@@ -1988,7 +2105,7 @@ export type ResourceLimits = {
* Supported Runtimes
*/
supported_runtimes: {
- [key: string]: Array;
+ [key: string]: LanguageInfo;
};
};
@@ -2139,13 +2256,9 @@ export type SseHealthResponse = {
*/
max_connections_per_user: number;
/**
- * Shutdown
- *
* Shutdown status information
*/
- shutdown: {
- [key: string]: unknown;
- };
+ shutdown: ShutdownStatusResponse;
/**
* Timestamp
*
@@ -2449,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
*
@@ -2666,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
*
@@ -3596,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];
@@ -3616,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];
@@ -4336,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;
@@ -4567,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;
@@ -4721,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];
@@ -4875,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];
@@ -4909,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/lib/auth-init.ts b/frontend/src/lib/auth-init.ts
index cd1592d..60c52f0 100644
--- a/frontend/src/lib/auth-init.ts
+++ b/frontend/src/lib/auth-init.ts
@@ -1,6 +1,7 @@
import { get } from 'svelte/store';
-import { isAuthenticated, username, userId, userRole, userEmail, csrfToken, verifyAuth } from '../stores/auth';
-import { loadUserSettings } from './user-settings';
+import { isAuthenticated, username, userId, userRole, userEmail, csrfToken, verifyAuth } from '$stores/auth';
+import { clearUserSettings } from '$stores/userSettings';
+import { loadUserSettings } from '$lib/user-settings';
interface PersistedAuth {
isAuthenticated: boolean;
@@ -126,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;
@@ -154,7 +147,8 @@ export class AuthInitializer {
userRole.set(null);
userEmail.set(null);
csrfToken.set(null);
- localStorage.removeItem('authState');
+ clearUserSettings();
+ sessionStorage.removeItem('authState');
}
static isAuthenticated(): boolean {
diff --git a/frontend/src/lib/editor/execution.svelte.ts b/frontend/src/lib/editor/execution.svelte.ts
new file mode 100644
index 0000000..bab42bb
--- /dev/null
+++ b/frontend/src/lib/editor/execution.svelte.ts
@@ -0,0 +1,113 @@
+import {
+ createExecutionApiV1ExecutePost,
+ getResultApiV1ResultExecutionIdGet,
+ type ExecutionResult,
+} from '$lib/api';
+import { getErrorMessage } from '$lib/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..b12a293
--- /dev/null
+++ b/frontend/src/lib/editor/index.ts
@@ -0,0 +1,2 @@
+export { getLanguageExtension } from '$lib/editor/languages';
+export { createExecutionState, type ExecutionState, type ExecutionPhase } from '$lib/editor/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/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..ce80440 100644
--- a/frontend/src/lib/user-settings.ts
+++ b/frontend/src/lib/user-settings.ts
@@ -1,49 +1,16 @@
import { get } from 'svelte/store';
-import { isAuthenticated } from '../stores/auth';
-import { setThemeLocal } from '../stores/theme';
-import { getCachedSettings, setCachedSettings, updateCachedSetting } from './settings-cache';
+import { isAuthenticated } from '$stores/auth';
+import { setTheme } from '$stores/theme';
+import { setUserSettings } from '$stores/userSettings';
import {
getUserSettingsApiV1UserSettingsGet,
- updateThemeApiV1UserSettingsThemePut,
- updateEditorSettingsApiV1UserSettingsEditorPut,
- type Theme,
- type EditorSettings,
+ updateUserSettingsApiV1UserSettingsPut,
type UserSettings,
-} 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;
- }
-}
+ type UserSettingsUpdate,
+} from '$lib/api';
+import { unwrap } from '$lib/api-interceptors';
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,10 +19,10 @@ export async function loadUserSettings(): Promise {
return;
}
- setCachedSettings(data);
+ setUserSettings(data);
if (data.theme) {
- setThemeLocal(data.theme);
+ setTheme(data.theme);
}
return data;
@@ -64,26 +31,20 @@ 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 data = unwrap(await updateUserSettingsApiV1UserSettingsPut({ body: partial }));
+ setUserSettings(data);
- if (error) {
- console.error('Failed to save editor settings');
- throw error;
+ if (data.theme) {
+ setTheme(data.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/main.ts b/frontend/src/main.ts
index 179d85f..7e89beb 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,8 +1,8 @@
import { mount } from 'svelte';
import App from './App.svelte';
-import ErrorDisplay from './components/ErrorDisplay.svelte';
-import { appError } from './stores/errorStore';
-import { initializeApiInterceptors } from './lib/api-interceptors';
+import ErrorDisplay from '$components/ErrorDisplay.svelte';
+import { appError } from '$stores/errorStore';
+import { initializeApiInterceptors } from '$lib/api-interceptors';
import './app.css';
initializeApiInterceptors();
@@ -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/Editor.svelte b/frontend/src/routes/Editor.svelte
index 60254f2..27ea05d 100644
--- a/frontend/src/routes/Editor.svelte
+++ b/frontend/src/routes/Editor.svelte
@@ -1,23 +1,36 @@
-
+
-
-
- {#if !editorView}
-
-
- Loading Editor...
-
- {:else if get(script).trim() === ''}
-
+
scriptName.set(n)} onexample={loadExampleScript} />
+
+
+ {#if $script.trim() === ''}
+
Editor is Empty
-
- Start typing, upload a file, or use an example to begin.
-
-
- {@html exampleIcon}
+ Start typing, upload a file, or use an example to begin.
+
+
Start with an Example
@@ -885,211 +325,30 @@
-
-
- Execution Output
-
-
- {#if executing}
-
-
-
Executing script...
-
- {:else if result}
-
-
-
Status: {result.status}
-
- {#if result.execution_id}
-
-
copyExecutionId(result.execution_id)}>
- {@html idIcon}
-
-
- Execution ID:
- {result.execution_id}
- Click to copy
-
-
- {/if}
-
-
- {#if result.stdout}
-
-
- Output:
-
-
{@html sanitizeOutput(ansiConverter.toHtml(result.stdout || ''))}
-
-
copyOutput(result.stdout)}>
- {@html copyIcon}
-
-
- Copy output
-
-
-
-
- {/if}
-
- {#if result.stderr}
-
-
- Errors:
-
-
-
{@html sanitizeOutput(ansiConverter.toHtml(result.stderr || ''))}
-
-
-
copyErrors(result.stderr)}>
- {@html copyIcon}
-
-
- 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}
-
-
+
-
-
-
showLangOptions = !showLangOptions}
- class="btn btn-secondary-outline btn-sm w-36 flex items-center justify-between text-left">
- {$selectedLang} {$selectedVersion}
-
-
-
-
-
- {#if showLangOptions}
-
-
hoveredLang = null}>
- {#each Object.entries(supportedRuntimes) as [lang, versions] (lang)}
- hoveredLang = lang}>
-
-
- {#if hoveredLang === lang && versions.length > 0}
-
- {/if}
-
- {/each}
- {#if Object.keys(supportedRuntimes).length === 0}
- No runtimes available
- {/if}
-
-
- {/if}
-
-
- {@html playIcon}
- {executing ? "Executing..." : "Run Script"}
+ { selectedLang.set(l); selectedVersion.set(v); }}
+ />
+
+
+ {execution.isExecuting ? 'Executing...' : 'Run Script'}
showOptions = !showOptions}
aria-expanded={showOptions}
- title={showOptions ? "Hide Options" : "Show Options"}>
+ title={showOptions ? 'Hide Options' : 'Show Options'}>
Toggle Script Options
- {@html settingsIcon}
+
@@ -1097,91 +356,27 @@
{#if showOptions}
1 - Math.pow(1 - t, 3) }}>
-
-
-
-
- File Actions
-
-
-
- {@html newFileIcon}New
-
- fileInput.click()} title="Upload a file">
- {@html uploadIcon}Upload
-
- {#if authenticated}
-
- {@html saveIcon}Save
-
- {/if}
-
- {@html exportIcon}Export
-
-
+
+ fileInput.click()}
+ onsave={saveScript}
+ onexport={exportScript}
+ />
-
-
-
-
-
+
{#if authenticated}
-
- Saved Scripts
-
-
-
- {@html listIcon}
- {showSavedScripts ? "Hide" : "Show"} Saved Scripts
-
-
- {#if showSavedScripts}
-
- {#if savedScripts.length > 0}
-
-
- {#each savedScripts as savedItem, index (savedItem.id || index)}
-
- loadScript(savedItem)}
- title={`Load ${savedItem.name} (${savedItem.lang || 'python'} ${savedItem.lang_version || '3.11'})`}>
-
- {savedItem.name}
-
- {savedItem.lang || 'python'} {savedItem.lang_version || '3.11'}
-
-
-
- { e.stopPropagation(); deleteScript(savedItem.id); }}
- title={`Delete ${savedItem.name}`}>
- Delete
- {@html trashIcon}
-
-
- {/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/routes/Home.svelte b/frontend/src/routes/Home.svelte
index 0ee845a..c43f040 100644
--- a/frontend/src/routes/Home.svelte
+++ b/frontend/src/routes/Home.svelte
@@ -1,17 +1,14 @@
@@ -170,8 +163,8 @@
{:else if $notifications.length === 0}
-
- {@html bellIcon}
+
+
No notifications yet
@@ -184,6 +177,7 @@
{:else}
{#each $notifications as notification (notification.notification_id)}
+ {@const NotifIcon = getNotificationIcon(notification.tags)}
- {@html getNotificationIcon(notification.tags)}
+
@@ -218,7 +212,7 @@
{#if deleting[notification.notification_id]}
{:else}
- {@html trashIcon}
+
{/if}
{#if (notification.tags || []).some(t => t.startsWith('exec:'))}
@@ -237,7 +231,7 @@
- {@html clockIcon}
+
{formatTimestamp(notification.created_at)}
diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte
index 582097e..4c0ab87 100644
--- a/frontend/src/routes/Register.svelte
+++ b/frontend/src/routes/Register.svelte
@@ -1,11 +1,11 @@
+
Event Browser
-
+
showFilters = !showFilters}
@@ -302,13 +253,13 @@
Filters
- {#if hasActiveFilters()}
+ {#if hasActiveFilters(filters)}
- {getActiveFilterCount()}
+ {getActiveFilterCount(filters)}
{/if}
-
+
-
- {#if loading}
-
- {:else}
-
- {/if}
+
+ {#if loading} {:else} {/if}
Refresh
-
- {#if activeReplaySession}
-
-
-
activeReplaySession = null}
- class="absolute top-2 right-2 p-1 hover:bg-blue-100 dark:hover:bg-blue-800 rounded-lg transition-colors"
- title="Close"
- >
-
-
-
-
-
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
-
-
- Clear All
-
- { currentPage = 1; loadEvents(); }}
- class="btn btn-primary btn-sm"
- >
- Apply
-
-
-
-
-
-
-
+ {#if showFilters}
+
{/if}
-
+
+
-
- Events
-
-
-
-
-
-
-
-
- Time
- Type
- User
- Service
- Actions
-
-
-
- {#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"
- >
-
-
- {new Date(event.timestamp).toLocaleDateString()}
-
-
- {new Date(event.timestamp).toLocaleTimeString()}
-
-
-
-
-
-
-
-
-
-
{event.event_type}
-
- {event.event_id.slice(0, 8)}...
-
-
-
-
-
-
-
- {#if event.metadata?.user_id}
- { e.stopPropagation(); openUserOverview(event.metadata.user_id); }}
- >
-
- {event.metadata.user_id}
-
-
- {:else}
- -
- {/if}
-
-
-
- {event.metadata?.service_name || '-'}
-
-
-
-
-
{ e.stopPropagation(); replayEvent(event.event_id); }}
- class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded"
- title="Preview replay"
- >
-
-
-
{ e.stopPropagation(); replayEvent(event.event_id, false); }}
- class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded text-blue-600 dark:text-blue-400"
- title="Replay"
- >
-
-
-
{ e.stopPropagation(); deleteEvent(event.event_id); }}
- class="p-1 hover:bg-interactive-hover dark:hover:bg-dark-interactive-hover rounded text-red-600 dark:text-red-400"
- title="Delete"
- >
-
-
-
-
-
- {/each}
-
-
+
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"
- >
-
-
-
-
-
-
-
-
-
-
{event.event_type}
-
- {event.event_id.slice(0, 8)}...
-
-
-
-
-
- {formatTimestamp(event.timestamp)}
-
-
-
-
-
{ e.stopPropagation(); replayEvent(event.event_id); }}
- class="btn btn-ghost btn-xs p-1"
- title="Preview replay"
- >
-
-
-
{ e.stopPropagation(); replayEvent(event.event_id, false); }}
- class="btn btn-ghost btn-xs p-1 text-blue-600 dark:text-blue-400"
- title="Replay"
- >
-
-
-
{ e.stopPropagation(); deleteEvent(event.event_id); }}
- class="btn btn-ghost btn-xs p-1 text-red-600 dark:text-red-400"
- title="Delete"
- >
-
-
-
-
-
-
- User:
- {#if event.metadata?.user_id}
- { e.stopPropagation(); openUserOverview(event.metadata.user_id); }}
- >
- {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}
-
-
Show:
per page
-
-
- {#if totalPages > 1}
-
-
- { currentPage = 1; loadEvents(); }}
- disabled={currentPage === 1}
- class="pagination-button"
- title="First page"
- >
-
-
-
-
- { currentPage--; loadEvents(); }}
- disabled={currentPage === 1}
- class="pagination-button"
- title="Previous page"
- >
-
-
-
-
-
-
-
- { currentPage++; loadEvents(); }}
- disabled={currentPage === totalPages}
- class="pagination-button"
- title="Next page"
- >
-
-
-
- { currentPage = totalPages; loadEvents(); }}
- disabled={currentPage === totalPages}
- class="pagination-button"
- title="Last page"
- >
-
-
-
+ {#if totalPages > 1}
+
+ { currentPage = 1; loadEvents(); }} disabled={currentPage === 1} class="pagination-button" title="First page">
+
+
+ { currentPage--; loadEvents(); }} disabled={currentPage === 1} class="pagination-button" title="Previous page">
+
+
+
+ { currentPage++; loadEvents(); }} disabled={currentPage === totalPages} class="pagination-button" title="Next page">
+
+
+ { currentPage = totalPages; loadEvents(); }} disabled={currentPage === totalPages} class="pagination-button" title="Last page">
+
+
+
{/if}
-
-
+
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, totalEvents)} of {totalEvents} events
- {/if}
+ {/if}
-
-
selectedEvent = null} size="lg">
- {#if selectedEvent}
-
-
-
Basic Information
-
-
-
- Event ID
- {selectedEvent.event.event_id}
-
-
- Event Type
-
-
-
-
-
-
- {selectedEvent.event.event_type}
-
-
-
-
-
- Timestamp
- {formatTimestamp(selectedEvent.event.timestamp)}
-
-
- Correlation ID
- {selectedEvent.event.correlation_id}
-
-
- Aggregate ID
- {selectedEvent.event.aggregate_id || '-'}
-
-
-
-
-
-
-
Metadata
-
{JSON.stringify(selectedEvent.event.metadata, null, 2)}
-
-
-
-
Payload
-
{JSON.stringify(selectedEvent.event.payload, null, 2)}
-
-
- {#if selectedEvent.related_events && selectedEvent.related_events.length > 0}
-
-
Related Events
-
- {#each selectedEvent.related_events || [] as related}
- loadEventDetail(related.event_id)}
- class="flex justify-between items-center w-full p-2 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
- >
-
- {related.event_type}
-
-
- {formatTimestamp(related.timestamp)}
-
-
- {/each}
-
-
- {/if}
-
- {/if}
-
- {#snippet footer()}
- selectedEvent && replayEvent(selectedEvent.event.event_id, false)}
- class="btn btn-primary"
- >
- Replay Event
-
- selectedEvent = null}
- class="btn btn-secondary-outline"
- >
- Close
-
- {/snippet}
-
-
-
-
{ showReplayPreview = false; replayPreview = null; }} size="md">
- {#if replayPreview}
-
- Review the events that will be replayed
-
-
-
-
-
-
- {replayPreview.total_events} event{replayPreview.total_events !== 1 ? 's' : ''} will be replayed
-
- Dry Run
-
-
-
-
- {#if replayPreview.events_preview && replayPreview.events_preview.length > 0}
-
-
Events to Replay:
- {#each replayPreview.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()}
- {
- showReplayPreview = false;
- if (replayPreview) replayEvent(replayPreview.eventId, false);
- }}
- class="btn btn-primary"
- >
- Proceed with Replay
-
- { showReplayPreview = false; replayPreview = null; }}
- class="btn btn-secondary-outline"
- >
- Cancel
-
- {/snippet}
-
-
-
-
showUserOverview = false} size="lg">
- {#if userOverviewLoading}
-
-
-
- {:else if userOverview}
-
-
-
-
Profile
-
-
User ID: {userOverview.user.user_id}
-
Username: {userOverview.user.username}
-
Email: {userOverview.user.email}
-
Role: {userOverview.user.role}
-
Active: {userOverview.user.is_active ? 'Yes' : 'No'}
-
Superuser: {userOverview.user.is_superuser ? 'Yes' : 'No'}
-
- {#if userOverview.rate_limit_summary}
-
-
Rate Limits
-
-
Bypass: {userOverview.rate_limit_summary.bypass_rate_limit ? 'Yes' : 'No'}
-
Global Multiplier: {userOverview.rate_limit_summary.global_multiplier ?? 1.0}
-
Custom Rules: {userOverview.rate_limit_summary.has_custom_limits ? 'Yes' : 'No'}
-
-
- {/if}
-
-
-
-
-
Execution Stats (last 24h)
-
-
-
Succeeded
-
{userOverview.derived_counts.succeeded}
-
-
-
Failed
-
{userOverview.derived_counts.failed}
-
-
-
Timeout
-
{userOverview.derived_counts.timeout}
-
-
-
Cancelled
-
{userOverview.derived_counts.cancelled}
-
-
-
- Terminal Total: {userOverview.derived_counts.terminal_total}
-
-
- Total Events: {userOverview.stats.total_events}
-
-
-
-
- {#if userOverview.recent_events && userOverview.recent_events.length > 0}
-
-
Recent Execution Events
-
- {#each userOverview.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}
-
+
+
+{#if selectedEvent}
+
selectedEvent = null}
+ onReplay={handleReplayFromModal}
+ onViewRelated={loadEventDetail}
+ />
+{/if}
+
+{#if showReplayPreview && replayPreview}
+ { showReplayPreview = false; replayPreview = null; }}
+ onConfirm={handleReplayConfirm}
+ />
+{/if}
+
+{#if showUserOverview}
+ showUserOverview = false}
+ />
+{/if}
diff --git a/frontend/src/routes/admin/AdminLayout.svelte b/frontend/src/routes/admin/AdminLayout.svelte
index 8a54f2b..7dde4b7 100644
--- a/frontend/src/routes/admin/AdminLayout.svelte
+++ b/frontend/src/routes/admin/AdminLayout.svelte
@@ -1,11 +1,12 @@
-
Saga Management
-
Monitor and debug distributed transactions
+
+ Saga Management
+
+
+ Monitor and debug distributed transactions
+
-
-
- {#each Object.entries(sagaStates) as [state, info]}
- {@const count = sagas.filter(s => s.state === state).length}
- {@const IconComponent = info.icon}
-
-
-
-
-
{info.label}
-
{count}
-
-
-
-
-
- {/each}
-
-
-
-
-
-
-
- Auto-refresh
-
-
- {#if autoRefresh}
-
- Every:
-
- 5 seconds
- 10 seconds
- 30 seconds
- 1 minute
-
-
- {/if}
-
-
- {#if loading}
- Refreshing...
- {:else}
- Refresh Now
- {/if}
-
-
-
+
-
-
-
- Search
-
-
-
- State
-
- All States
- {#each Object.entries(sagaStates) as [value, state]}
- {state.label}
- {/each}
-
-
-
- Execution ID
-
-
-
- Actions
-
- Clear Filters
-
-
+
-
-
- {#if loading && sagas.length === 0}
-
- {: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))}%
-
-
-
-
- loadExecutionSagas(saga.execution_id)} class="flex-1 btn btn-sm btn-secondary-outline">Execution
- loadSagaDetails(saga.saga_id)} class="flex-1 btn btn-sm btn-primary">View Details
-
-
- {/each}
-
-
-
-
-
-
-
- {#each sagas as saga}
-
-
- {saga.saga_name}
- ID: {saga.saga_id.slice(0, 8)}...
- loadExecutionSagas(saga.execution_id)} class="text-xs text-primary hover:text-primary-dark">
- Execution: {saga.execution_id.slice(0, 8)}...
-
-
-
- {getStateInfo(saga.state).label}
- {#if saga.retry_count > 0}(Retry: {saga.retry_count}) {/if}
-
-
-
-
-
-
{saga.completed_steps.length}
-
- {#if saga.current_step}
Current: {saga.current_step}
{/if}
-
-
- {formatTimestamp(saga.created_at)}
- {formatDurationBetween(saga.created_at, saga.completed_at || saga.updated_at)}
-
- loadSagaDetails(saga.saga_id)} class="text-primary hover:text-primary-dark">View Details
-
-
- {/each}
-
-
-
-
-
- {#if totalItems > 0}
-
+
+
+
+
+ {#if totalItems > 0}
+
+ {#if hasClientFilters && sagas.length < serverReturnedCount}
+
+ Showing {sagas.length} of {serverReturnedCount} on this page (filtered locally)
+
{/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
- { showDetailModal = false; loadExecutionSagas(selectedSaga.execution_id); }} class="text-primary hover:text-primary-dark font-mono">{selectedSaga.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/AdminSettings.svelte b/frontend/src/routes/admin/AdminSettings.svelte
index 928f01f..e4981ff 100644
--- a/frontend/src/routes/admin/AdminSettings.svelte
+++ b/frontend/src/routes/admin/AdminSettings.svelte
@@ -4,10 +4,10 @@
getSystemSettingsApiV1AdminSettingsGet,
updateSystemSettingsApiV1AdminSettingsPut,
resetSystemSettingsApiV1AdminSettingsResetPost,
- } from '../../lib/api';
- import { addToast } from '../../stores/toastStore';
- import AdminLayout from './AdminLayout.svelte';
- import Spinner from '../../components/Spinner.svelte';
+ } from '$lib/api';
+ import { addToast } from '$stores/toastStore';
+ import AdminLayout from '$routes/admin/AdminLayout.svelte';
+ import Spinner from '$components/Spinner.svelte';
let settings = $state<{
execution_limits: Record;
diff --git a/frontend/src/routes/admin/AdminUsers.svelte b/frontend/src/routes/admin/AdminUsers.svelte
index 0fa1e74..fa2f969 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';
-
+ } from '$lib/api';
+ import { unwrap, unwrapOr } from '$lib/api-interceptors';
+ import { addToast } from '$stores/toastStore';
+ import AdminLayout from '$routes/admin/AdminLayout.svelte';
+ import Spinner from '$components/Spinner.svelte';
+ import Pagination from '$components/Pagination.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(false);
+ 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;
+ interface AdvancedFilters {
+ bypassRateLimit: string;
+ hasCustomLimits: string;
+ globalMultiplier: string;
}
- 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 = {};
- }
-
- 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 @@
-
-
-
-
-
- Search
-
-
-
- Role
-
- All Roles User Admin
-
-
-
- Status
-
- All Status Active Disabled
-
-
-
showAdvancedFilters = !showAdvancedFilters} class="btn btn-outline flex items-center gap-2 w-full sm:w-auto justify-center">
- Advanced
-
-
- Reset
-
-
-
- {#if showAdvancedFilters}
-
-
Rate Limit Filters
-
-
- Bypass Rate Limit
-
- All Yes (Bypassed) No (Limited)
-
-
-
- Custom Limits
-
- All Has Custom Default Only
-
-
-
- Global Multiplier
-
- All Custom (≠ 1.0) Default (= 1.0)
-
-
-
-
- {/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)}
-
-
openEditUserModal(user)} class="flex-1 btn btn-sm btn-outline flex items-center justify-center gap-1">
- Edit
-
-
openRateLimitModal(user)} class="flex-1 btn btn-sm btn-outline flex items-center justify-center gap-1">
- Limits
-
-
{ userToDelete = user; showDeleteModal = true; }} class="btn btn-sm btn-danger flex items-center justify-center gap-1">
- Delete
-
-
-
- {/each}
-
-
-
-
-
-
-
- {#each paginatedUsers as user}
-
- {user.username}
- {user.email || '-'}
- {user.role}
- {formatTimestamp(user.created_at)}
- {user.is_active ? 'Active' : 'Inactive'}
-
-
-
openEditUserModal(user)} class="text-green-600 hover:text-green-800 dark:text-green-400" title="Edit User">
-
-
-
openRateLimitModal(user)} class="text-blue-600 hover:text-blue-800 dark:text-blue-400" title="Manage Rate Limits">
-
-
-
{ userToDelete = user; showDeleteModal = true; }} class="text-red-600 hover:text-red-800 dark:text-red-400" title="Delete User">
-
-
-
-
-
- {/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} ?
-
-
-
-
- Delete all user data (executions, scripts, etc.)
-
-
- {#if cascadeDelete}
-
- Warning: This will permanently delete all data associated with this user.
-
- {/if}
-
- { showDeleteModal = false; userToDelete = null; }} class="btn btn-secondary" disabled={deletingUser}>Cancel
-
- {#if deletingUser} Deleting...{:else}Delete 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
-
-
- Bypass all rate limits
-
-
-
-
-
Global Multiplier
-
-
Multiplies all limits (1.0 = default, 2.0 = double)
-
-
- Admin Notes
-
-
-
-
-
-
-
-
-
Endpoint Rate Limits
-
addNewRule()} class="btn btn-sm btn-primary flex items-center gap-1" disabled={rateLimitConfig.bypass_rate_limit}>
- Add Rule
-
-
-
-
-
-
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}
-
- {/each}
-
-
- {/if}
-
-
-
- {#if rateLimitUsage && Object.keys(rateLimitUsage).length > 0}
-
-
-
Current Usage
- Reset All Counters
-
-
- {#each Object.entries(rateLimitUsage) as [endpoint, usage]}
-
- {endpoint}
- {usage.count || usage.tokens_remaining || 0}
-
- {/each}
-
-
- {/if}
-
-
- { showRateLimitModal = false; rateLimitUser = null; rateLimitConfig = null; }} class="btn btn-secondary" disabled={savingRateLimits}>Cancel
-
- {#if savingRateLimits} Saving...{:else}Save Changes{/if}
-
-
- {/if}
- {/snippet}
-
+ { showRateLimitModal = false; rateLimitUser = null; rateLimitConfig = null; }}
+ onSave={saveRateLimits}
+ onReset={resetRateLimits}
+ />
{/if}
-
{#if showUserModal}
- showUserModal = false} size="sm">
- {#snippet children()}
-
- {/snippet}
-
+ showUserModal = false}
+ onSave={saveUser}
+ />
{/if}
diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts
index 69fd88b..a2e35dd 100644
--- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts
+++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts
@@ -2,45 +2,44 @@ 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';
-
-// Mock data factories
-function createMockEvent(overrides: Partial<{
- event_id: string;
- event_type: string;
- timestamp: string;
- correlation_id: string;
- aggregate_id: string;
- metadata: Record;
- payload: Record;
-}> = {}) {
- return {
- event_id: 'evt-1',
- event_type: 'execution.completed',
- timestamp: '2024-01-15T10:30:00Z',
- correlation_id: 'corr-123',
- aggregate_id: 'exec-456',
- metadata: { user_id: 'user-1', service_name: 'test-service' },
- payload: { output: 'hello', exit_code: 0 },
- ...overrides,
- };
+import { mockElementAnimate, mockWindowGlobals } from '$routes/admin/__tests__/test-utils';
+
+interface MockEventOverrides {
+ event_id?: string;
+ event_type?: string;
+ timestamp?: string;
+ correlation_id?: string;
+ aggregate_id?: string;
+ metadata?: Record;
+ payload?: Record;
}
-function createMockEvents(count: number) {
- const eventTypes = [
- 'execution.requested', 'execution.started', 'execution.completed',
- 'execution.failed', 'pod.created', 'pod.running'
- ];
- return Array.from({ length: count }, (_, i) =>
- createMockEvent({
- event_id: `evt-${i + 1}`,
- event_type: eventTypes[i % eventTypes.length],
- timestamp: new Date(Date.now() - i * 60000).toISOString(),
- correlation_id: `corr-${i + 1}`,
- aggregate_id: `exec-${i + 1}`,
- metadata: { user_id: `user-${(i % 3) + 1}`, service_name: 'execution-service' },
- })
- );
-}
+const DEFAULT_EVENT = {
+ event_id: 'evt-1',
+ event_type: 'execution.completed',
+ timestamp: '2024-01-15T10:30:00Z',
+ correlation_id: 'corr-123',
+ aggregate_id: 'exec-456',
+ metadata: { user_id: 'user-1', service_name: 'test-service' },
+ payload: { output: 'hello', exit_code: 0 },
+};
+
+const EVENT_TYPES = [
+ 'execution.requested', 'execution.started', 'execution.completed',
+ 'execution.failed', 'pod.created', 'pod.running'
+];
+
+const createMockEvent = (overrides: MockEventOverrides = {}) => ({ ...DEFAULT_EVENT, ...overrides });
+
+const createMockEvents = (count: number) =>
+ Array.from({ length: count }, (_, i) => createMockEvent({
+ event_id: `evt-${i + 1}`,
+ event_type: EVENT_TYPES[i % EVENT_TYPES.length],
+ timestamp: new Date(Date.now() - i * 60000).toISOString(),
+ correlation_id: `corr-${i + 1}`,
+ aggregate_id: `exec-${i + 1}`,
+ metadata: { user_id: `user-${(i % 3) + 1}`, service_name: 'execution-service' },
+ }));
function createMockStats(overrides: Partial<{
total_events: number;
@@ -123,30 +122,11 @@ vi.mock('@mateothegreat/svelte5-router', () => ({
// Simple mock for AdminLayout
vi.mock('../AdminLayout.svelte', async () => {
- const { default: MockLayout } = await import('./mocks/MockAdminLayout.svelte');
+ const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte');
return { default: MockLayout };
});
-import AdminEvents from '../AdminEvents.svelte';
-
-// Setup helpers
-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,
- }));
-
- // Mock window.open and window.confirm
- vi.stubGlobal('open', mocks.windowOpen);
- vi.stubGlobal('confirm', mocks.windowConfirm);
-}
+import AdminEvents from '$routes/admin/AdminEvents.svelte';
async function renderWithEvents(events = createMockEvents(5), stats = createMockStats()) {
mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({
@@ -164,7 +144,8 @@ async function renderWithEvents(events = createMockEvents(5), stats = createMock
describe('AdminEvents', () => {
beforeEach(() => {
vi.useFakeTimers();
- setupMocks();
+ mockElementAnimate();
+ mockWindowGlobals(mocks.windowOpen, mocks.windowConfirm);
vi.clearAllMocks();
mocks.browseEventsApiV1AdminEventsBrowsePost.mockResolvedValue({ data: { events: [], total: 0 }, error: null });
mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: null, error: null });
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..fc62037
--- /dev/null
+++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts
@@ -0,0 +1,476 @@
+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';
+import { mockElementAnimate } from '$routes/admin/__tests__/test-utils';
+
+interface MockSagaOverrides {
+ 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;
+}
+
+const DEFAULT_SAGA = {
+ 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: [] as string[],
+ retry_count: 0,
+ error_message: null as string | null,
+ context_data: { key: 'value' },
+ created_at: '2024-01-15T10:30:00Z',
+ updated_at: '2024-01-15T10:31:00Z',
+ completed_at: null as string | null,
+};
+
+const SAGA_STATES = ['created', 'running', 'completed', 'failed', 'compensating', 'timeout'];
+
+const createMockSaga = (overrides: MockSagaOverrides = {}) => ({ ...DEFAULT_SAGA, ...overrides });
+
+const createMockSagas = (count: number) =>
+ Array.from({ length: count }, (_, i) => createMockSaga({
+ saga_id: `saga-${i + 1}`,
+ execution_id: `exec-${i + 1}`,
+ state: SAGA_STATES[i % SAGA_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('$routes/admin/__tests__/mocks/MockAdminLayout.svelte');
+ return { default: MockLayout };
+});
+
+import AdminSagas from '$routes/admin/AdminSagas.svelte';
+
+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();
+ mockElementAnimate();
+ 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);
+ });
+ });
+});
diff --git a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts
index b6b96b0..87752f3 100644
--- a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts
+++ b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts
@@ -2,47 +2,45 @@ 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';
-
-// Mock data factories
-function createMockUser(overrides: Partial<{
- user_id: string;
- username: string;
- email: string | null;
- role: string;
- is_active: boolean;
- is_disabled: boolean;
- created_at: string;
+import { mockElementAnimate } from '$routes/admin/__tests__/test-utils';
+
+interface MockUserOverrides {
+ user_id?: string;
+ username?: string;
+ email?: string | null;
+ role?: string;
+ is_active?: boolean;
+ is_disabled?: boolean;
+ created_at?: string;
bypass_rate_limit?: boolean;
has_custom_limits?: boolean;
global_multiplier?: number;
-}> = {}) {
- return {
- user_id: 'user-1',
- username: 'testuser',
- email: 'test@example.com',
- role: 'user',
- is_active: true,
- is_disabled: false,
- created_at: '2024-01-15T10:30:00Z',
- bypass_rate_limit: false,
- has_custom_limits: false,
- global_multiplier: 1.0,
- ...overrides,
- };
}
-function createMockUsers(count: number) {
- return Array.from({ length: count }, (_, i) =>
- createMockUser({
- user_id: `user-${i + 1}`,
- username: `user${i + 1}`,
- email: `user${i + 1}@example.com`,
- role: i === 0 ? 'admin' : 'user',
- is_active: i % 3 !== 0,
- is_disabled: i % 3 === 0,
- })
- );
-}
+const DEFAULT_USER = {
+ user_id: 'user-1',
+ username: 'testuser',
+ email: 'test@example.com',
+ role: 'user',
+ is_active: true,
+ is_disabled: false,
+ created_at: '2024-01-15T10:30:00Z',
+ bypass_rate_limit: false,
+ has_custom_limits: false,
+ global_multiplier: 1.0,
+};
+
+const createMockUser = (overrides: MockUserOverrides = {}) => ({ ...DEFAULT_USER, ...overrides });
+
+const createMockUsers = (count: number) =>
+ Array.from({ length: count }, (_, i) => createMockUser({
+ user_id: `user-${i + 1}`,
+ username: `user${i + 1}`,
+ email: `user${i + 1}@example.com`,
+ role: i === 0 ? 'admin' : 'user',
+ is_active: i % 3 !== 0,
+ is_disabled: i % 3 === 0,
+ }));
// Hoisted mocks - must be self-contained
const mocks = vi.hoisted(() => ({
@@ -88,43 +86,11 @@ vi.mock('@mateothegreat/svelte5-router', () => ({
// Simple mock for AdminLayout that just renders children
vi.mock('../AdminLayout.svelte', async () => {
- const { default: MockLayout } = await import('./mocks/MockAdminLayout.svelte');
+ const { default: MockLayout } = await import('$routes/admin/__tests__/mocks/MockAdminLayout.svelte');
return { default: MockLayout };
});
-import AdminUsers from '../AdminUsers.svelte';
-
-// Setup helpers
-function setupMocks() {
- // Setup Element.prototype.animate for Svelte transitions
- 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,
- }));
-}
+import AdminUsers from '$routes/admin/AdminUsers.svelte';
async function renderWithUsers(users = createMockUsers(3)) {
mocks.listUsersApiV1AdminUsersGet.mockResolvedValue({ data: users, error: null });
@@ -136,7 +102,7 @@ async function renderWithUsers(users = createMockUsers(3)) {
describe('AdminUsers', () => {
beforeEach(() => {
- setupMocks();
+ mockElementAnimate();
vi.clearAllMocks();
mocks.listUsersApiV1AdminUsersGet.mockResolvedValue({ data: [], error: null });
});
@@ -425,6 +391,28 @@ describe('AdminUsers', () => {
});
});
+ it('confirms deletion without cascade by default', async () => {
+ mocks.deleteUserApiV1AdminUsersUserIdDelete.mockResolvedValue({ data: { message: 'Deleted' }, error: null });
+ const users = [createMockUser({ user_id: 'del1', username: 'deleteme' })];
+ const user = userEvent.setup();
+ await renderWithUsers(users);
+
+ const deleteButtons = screen.getAllByTitle('Delete User');
+ await user.click(deleteButtons[0]);
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
+
+ const allDeleteButtons = screen.getAllByRole('button', { name: /Delete User/i });
+ const confirmDeleteBtn = allDeleteButtons.find(btn => btn.closest('[role="dialog"]'));
+ await user.click(confirmDeleteBtn!);
+
+ await waitFor(() => {
+ expect(mocks.deleteUserApiV1AdminUsersUserIdDelete).toHaveBeenCalledWith({
+ path: { user_id: 'del1' },
+ query: { cascade: false },
+ });
+ });
+ });
+
it('confirms deletion with cascade option', async () => {
mocks.deleteUserApiV1AdminUsersUserIdDelete.mockResolvedValue({ data: { message: 'Deleted' }, error: null });
const users = [createMockUser({ user_id: 'del1', username: 'deleteme' })];
@@ -435,6 +423,10 @@ describe('AdminUsers', () => {
await user.click(deleteButtons[0]);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
+ // Enable cascade delete (defaults to false for safety)
+ const cascadeCheckbox = screen.getByRole('checkbox');
+ await user.click(cascadeCheckbox);
+
// Find the delete button in the modal (not the action button)
const allDeleteButtons = screen.getAllByRole('button', { name: /Delete User/i });
const confirmDeleteBtn = allDeleteButtons.find(btn => btn.closest('[role="dialog"]'));
@@ -493,6 +485,14 @@ describe('AdminUsers', () => {
const deleteButtons = screen.getAllByTitle('Delete User');
await user.click(deleteButtons[0]);
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
+
+ // Warning should not show when cascade is disabled (default)
+ expect(screen.queryByText(/permanently delete all data/i)).not.toBeInTheDocument();
+
+ // Enable cascade delete
+ const cascadeCheckbox = screen.getByRole('checkbox');
+ await user.click(cascadeCheckbox);
await waitFor(() => {
expect(screen.getByText(/permanently delete all data/i)).toBeInTheDocument();
diff --git a/frontend/src/routes/admin/__tests__/test-utils.ts b/frontend/src/routes/admin/__tests__/test-utils.ts
new file mode 100644
index 0000000..12390b1
--- /dev/null
+++ b/frontend/src/routes/admin/__tests__/test-utils.ts
@@ -0,0 +1,44 @@
+import { vi, type Mock } from 'vitest';
+
+const animateMock = {
+ 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,
+};
+
+/**
+ * Creates a mock for Element.prototype.animate used by Svelte transitions.
+ */
+export function mockElementAnimate(): void {
+ Element.prototype.animate = vi.fn().mockImplementation(() => animateMock);
+}
+
+/**
+ * Sets up window global mocks for open and confirm.
+ */
+export function mockWindowGlobals(openMock: Mock, confirmMock: Mock): void {
+ vi.stubGlobal('open', openMock);
+ vi.stubGlobal('confirm', confirmMock);
+}
diff --git a/frontend/src/stores/__tests__/auth.test.ts b/frontend/src/stores/__tests__/auth.test.ts
index 9e454ba..ec41d57 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
@@ -45,21 +45,21 @@ describe('auth store', () => {
describe('initial state', () => {
it('has null authentication state initially', async () => {
- const { isAuthenticated } = await import('../auth');
+ const { isAuthenticated } = await import('$stores/auth');
expect(get(isAuthenticated)).toBe(null);
});
it('has null username initially', async () => {
- const { username } = await import('../auth');
+ const { username } = await import('$stores/auth');
expect(get(username)).toBe(null);
});
it('has null userRole initially', async () => {
- const { userRole } = await import('../auth');
+ const { userRole } = await import('$stores/auth');
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,33 +69,15 @@ 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');
+ const { isAuthenticated, username, userRole, csrfToken } = await import('$stores/auth');
expect(get(isAuthenticated)).toBe(true);
expect(get(username)).toBe('testuser');
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', () => {
@@ -113,7 +95,7 @@ describe('auth store', () => {
error: null,
});
- const { login, isAuthenticated, username, userRole, csrfToken } = await import('../auth');
+ const { login, isAuthenticated, username, userRole, csrfToken } = await import('$stores/auth');
const result = await login('testuser', 'password123');
@@ -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',
@@ -138,10 +120,10 @@ describe('auth store', () => {
error: null,
});
- const { login } = await import('../auth');
+ const { login } = await import('$stores/auth');
await login('testuser', 'password123');
- expect(localStorage.setItem).toHaveBeenCalledWith(
+ expect(sessionStorage.setItem).toHaveBeenCalledWith(
'authState',
expect.stringContaining('testuser')
);
@@ -153,7 +135,7 @@ describe('auth store', () => {
error: { detail: 'Invalid credentials' },
});
- const { login } = await import('../auth');
+ const { login } = await import('$stores/auth');
await expect(login('baduser', 'badpass')).rejects.toBeDefined();
});
@@ -168,7 +150,7 @@ describe('auth store', () => {
error: null,
});
- const { login } = await import('../auth');
+ const { login } = await import('$stores/auth');
await login('testuser', 'mypassword');
expect(mockLoginApi).toHaveBeenCalledWith({
@@ -190,7 +172,7 @@ describe('auth store', () => {
});
mockLogoutApi.mockResolvedValue({ data: {}, error: null });
- const { login, logout, isAuthenticated, username } = await import('../auth');
+ const { login, logout, isAuthenticated, username } = await import('$stores/auth');
await login('testuser', 'password');
expect(get(isAuthenticated)).toBe(true);
@@ -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');
+ const { logout } = await import('$stores/auth');
await logout();
- expect(localStorage.removeItem).toHaveBeenCalledWith('authState');
+ expect(sessionStorage.removeItem).toHaveBeenCalledWith('authState');
});
it('still clears state even if API call fails', async () => {
@@ -223,7 +205,7 @@ describe('auth store', () => {
});
mockLogoutApi.mockRejectedValue(new Error('Network error'));
- const { login, logout, isAuthenticated } = await import('../auth');
+ const { login, logout, isAuthenticated } = await import('$stores/auth');
await login('testuser', 'password');
await logout();
@@ -244,7 +226,7 @@ describe('auth store', () => {
error: null,
});
- const { verifyAuth } = await import('../auth');
+ const { verifyAuth } = await import('$stores/auth');
const result = await verifyAuth(true);
expect(result).toBe(true);
@@ -256,7 +238,7 @@ describe('auth store', () => {
error: null,
});
- const { verifyAuth, isAuthenticated } = await import('../auth');
+ const { verifyAuth, isAuthenticated } = await import('$stores/auth');
const result = await verifyAuth(true);
expect(result).toBe(false);
@@ -273,7 +255,7 @@ describe('auth store', () => {
error: null,
});
- const { verifyAuth } = await import('../auth');
+ const { verifyAuth } = await import('$stores/auth');
// First call - should hit API
await verifyAuth(true);
@@ -294,7 +276,7 @@ describe('auth store', () => {
error: null,
});
- const { verifyAuth } = await import('../auth');
+ const { verifyAuth } = await import('$stores/auth');
await verifyAuth(true);
await verifyAuth(true);
@@ -315,7 +297,7 @@ describe('auth store', () => {
error: null,
});
- const { verifyAuth } = await import('../auth');
+ const { verifyAuth } = await import('$stores/auth');
const firstResult = await verifyAuth(true);
expect(firstResult).toBe(true);
@@ -341,7 +323,7 @@ describe('auth store', () => {
error: null,
});
- const { login, userId, userEmail } = await import('../auth');
+ const { login, userId, userEmail } = await import('$stores/auth');
await login('testuser', 'password');
expect(get(userId)).toBe('user-456');
@@ -354,7 +336,7 @@ describe('auth store', () => {
error: { detail: 'Unauthorized' },
});
- const { fetchUserProfile } = await import('../auth');
+ const { fetchUserProfile } = await import('$stores/auth');
await expect(fetchUserProfile()).rejects.toBeDefined();
});
diff --git a/frontend/src/stores/__tests__/errorStore.test.ts b/frontend/src/stores/__tests__/errorStore.test.ts
index e29aa52..464e5d6 100644
--- a/frontend/src/stores/__tests__/errorStore.test.ts
+++ b/frontend/src/stores/__tests__/errorStore.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { get } from 'svelte/store';
-import { appError } from '../errorStore';
+import { appError } from '$stores/errorStore';
describe('errorStore', () => {
beforeEach(() => {
diff --git a/frontend/src/stores/__tests__/notificationStore.test.ts b/frontend/src/stores/__tests__/notificationStore.test.ts
index d45e595..d4f371f 100644
--- a/frontend/src/stores/__tests__/notificationStore.test.ts
+++ b/frontend/src/stores/__tests__/notificationStore.test.ts
@@ -43,17 +43,17 @@ describe('notificationStore', () => {
describe('initial state', () => {
it('has empty notifications array', async () => {
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
expect(get(notificationStore).notifications).toEqual([]);
});
it('has loading false', async () => {
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
expect(get(notificationStore).loading).toBe(false);
});
it('has null error', async () => {
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
expect(get(notificationStore).error).toBe(null);
});
});
@@ -67,7 +67,7 @@ describe('notificationStore', () => {
});
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
const loadPromise = notificationStore.load();
capturedLoading = get(notificationStore).loading;
@@ -86,7 +86,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(notificationStore).notifications).toHaveLength(2);
@@ -99,7 +99,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(notificationStore).loading).toBe(false);
@@ -112,7 +112,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
const result = await notificationStore.load();
expect(result).toEqual(notifications);
@@ -124,7 +124,7 @@ describe('notificationStore', () => {
error: { detail: [{ msg: 'Failed to fetch' }] },
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(notificationStore).error).toBe('Failed to fetch');
@@ -136,7 +136,7 @@ describe('notificationStore', () => {
error: { detail: [{ msg: 'Error' }] },
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
const result = await notificationStore.load();
expect(result).toEqual([]);
@@ -148,7 +148,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load(50);
expect(mockGetNotifications).toHaveBeenCalledWith({
@@ -162,7 +162,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load(20, {
include_tags: ['important'],
exclude_tags: ['spam'],
@@ -187,7 +187,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const newNotification = createMockNotification({ notification_id: 'new', subject: 'New' });
@@ -199,7 +199,7 @@ describe('notificationStore', () => {
});
it('caps notifications at 100', async () => {
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
// Add 100 notifications
for (let i = 0; i < 100; i++) {
@@ -229,7 +229,7 @@ describe('notificationStore', () => {
});
mockMarkRead.mockResolvedValue({ error: null });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const result = await notificationStore.markAsRead('n1');
@@ -245,7 +245,7 @@ describe('notificationStore', () => {
});
mockMarkRead.mockResolvedValue({ error: { detail: 'Failed' } });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const result = await notificationStore.markAsRead('n1');
@@ -257,7 +257,7 @@ describe('notificationStore', () => {
it('calls API with correct notification ID', async () => {
mockMarkRead.mockResolvedValue({ error: null });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.markAsRead('test-id-123');
expect(mockMarkRead).toHaveBeenCalledWith({
@@ -279,7 +279,7 @@ describe('notificationStore', () => {
});
mockMarkAllRead.mockResolvedValue({ error: null });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const result = await notificationStore.markAllAsRead();
@@ -292,7 +292,7 @@ describe('notificationStore', () => {
it('returns false on failure', async () => {
mockMarkAllRead.mockResolvedValue({ error: { detail: 'Failed' } });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
const result = await notificationStore.markAllAsRead();
expect(result).toBe(false);
@@ -312,7 +312,7 @@ describe('notificationStore', () => {
});
mockDeleteNotification.mockResolvedValue({ error: null });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const result = await notificationStore.delete('n1');
@@ -330,7 +330,7 @@ describe('notificationStore', () => {
});
mockDeleteNotification.mockResolvedValue({ error: { detail: 'Failed' } });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
const result = await notificationStore.delete('n1');
@@ -342,7 +342,7 @@ describe('notificationStore', () => {
it('calls API with correct notification ID', async () => {
mockDeleteNotification.mockResolvedValue({ error: null });
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.delete('delete-me-123');
expect(mockDeleteNotification).toHaveBeenCalledWith({
@@ -363,7 +363,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(notificationStore).notifications).toHaveLength(2);
@@ -380,7 +380,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore } = await import('../notificationStore');
+ const { notificationStore } = await import('$stores/notificationStore');
await notificationStore.refresh();
expect(mockGetNotifications).toHaveBeenCalledWith({
@@ -402,7 +402,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore, unreadCount } = await import('../notificationStore');
+ const { notificationStore, unreadCount } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(unreadCount)).toBe(2);
@@ -418,7 +418,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore, unreadCount } = await import('../notificationStore');
+ const { notificationStore, unreadCount } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(unreadCount)).toBe(0);
@@ -435,7 +435,7 @@ describe('notificationStore', () => {
});
mockMarkRead.mockResolvedValue({ error: null });
- const { notificationStore, unreadCount } = await import('../notificationStore');
+ const { notificationStore, unreadCount } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(unreadCount)).toBe(1);
@@ -455,7 +455,7 @@ describe('notificationStore', () => {
error: null,
});
- const { notificationStore, notifications } = await import('../notificationStore');
+ const { notificationStore, notifications } = await import('$stores/notificationStore');
await notificationStore.load();
expect(get(notifications)).toHaveLength(2);
diff --git a/frontend/src/stores/__tests__/theme.test.ts b/frontend/src/stores/__tests__/theme.test.ts
index b2b75ba..24babeb 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', () => ({
@@ -39,51 +39,51 @@ describe('theme store', () => {
describe('initial state', () => {
it('defaults to auto when no stored value', async () => {
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
expect(get(theme)).toBe('auto');
});
it('loads stored theme from localStorage', async () => {
localStorageData['app-theme'] = 'dark';
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
expect(get(theme)).toBe('dark');
});
it('ignores invalid stored values', async () => {
localStorageData['app-theme'] = 'invalid';
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
expect(get(theme)).toBe('auto');
});
it('accepts light theme from storage', async () => {
localStorageData['app-theme'] = 'light';
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
expect(get(theme)).toBe('light');
});
});
describe('theme.set', () => {
it('updates the store value', async () => {
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('dark');
expect(get(theme)).toBe('dark');
});
it('persists to localStorage', async () => {
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('dark');
expect(localStorage.setItem).toHaveBeenCalledWith('app-theme', 'dark');
});
it('applies dark class when set to dark', async () => {
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('removes dark class when set to light', async () => {
document.documentElement.classList.add('dark');
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
@@ -92,7 +92,7 @@ describe('theme store', () => {
describe('toggleTheme', () => {
it('cycles from light to dark', async () => {
localStorageData['app-theme'] = 'light';
- const { theme, toggleTheme } = await import('../theme');
+ const { theme, toggleTheme } = await import('$stores/theme');
toggleTheme();
expect(get(theme)).toBe('dark');
@@ -100,21 +100,21 @@ describe('theme store', () => {
it('cycles from dark to auto', async () => {
localStorageData['app-theme'] = 'dark';
- const { theme, toggleTheme } = await import('../theme');
+ const { theme, toggleTheme } = await import('$stores/theme');
toggleTheme();
expect(get(theme)).toBe('auto');
});
it('cycles from auto to light', async () => {
- const { theme, toggleTheme } = await import('../theme');
+ const { theme, toggleTheme } = await import('$stores/theme');
// Default is auto
toggleTheme();
expect(get(theme)).toBe('light');
});
it('completes full cycle', async () => {
- const { theme, toggleTheme } = await import('../theme');
+ const { theme, toggleTheme } = await import('$stores/theme');
expect(get(theme)).toBe('auto');
toggleTheme();
@@ -128,7 +128,7 @@ describe('theme store', () => {
describe('setTheme', () => {
it('sets theme to specified value', async () => {
- const { theme, setTheme } = await import('../theme');
+ const { theme, setTheme } = await import('$stores/theme');
setTheme('dark');
expect(get(theme)).toBe('dark');
@@ -141,32 +141,12 @@ describe('theme store', () => {
});
it('persists to localStorage', async () => {
- const { setTheme } = await import('../theme');
+ const { setTheme } = await import('$stores/theme');
setTheme('dark');
expect(localStorage.setItem).toHaveBeenCalledWith('app-theme', 'dark');
});
});
- 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) => ({
@@ -180,7 +160,7 @@ describe('theme store', () => {
dispatchEvent: vi.fn(),
}));
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('auto');
expect(document.documentElement.classList.contains('dark')).toBe(false);
@@ -199,7 +179,7 @@ describe('theme store', () => {
}));
vi.resetModules();
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
theme.set('auto');
expect(document.documentElement.classList.contains('dark')).toBe(true);
@@ -208,7 +188,7 @@ describe('theme store', () => {
describe('subscription', () => {
it('notifies subscribers on change', async () => {
- const { theme } = await import('../theme');
+ const { theme } = await import('$stores/theme');
const values: string[] = [];
const unsubscribe = theme.subscribe((value) => {
diff --git a/frontend/src/stores/__tests__/toastStore.test.ts b/frontend/src/stores/__tests__/toastStore.test.ts
index 145eb2c..da0f372 100644
--- a/frontend/src/stores/__tests__/toastStore.test.ts
+++ b/frontend/src/stores/__tests__/toastStore.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { get } from 'svelte/store';
-import { toasts, addToast, removeToast, TOAST_DURATION } from '../toastStore';
+import { toasts, addToast, removeToast, TOAST_DURATION } from '$stores/toastStore';
describe('toastStore', () => {
beforeEach(() => {
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
index d456d0a..25faaf0 100644
--- a/frontend/src/stores/auth.ts
+++ b/frontend/src/stores/auth.ts
@@ -4,7 +4,7 @@ import {
logoutApiV1AuthLogoutPost,
verifyTokenApiV1AuthVerifyTokenGet,
getCurrentUserProfileApiV1AuthMeGet,
-} from '../lib/api';
+} from '$lib/api';
interface AuthState {
isAuthenticated: boolean | null;
@@ -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();
diff --git a/frontend/src/stores/notificationStore.ts b/frontend/src/stores/notificationStore.ts
index 657ede6..9a4216e 100644
--- a/frontend/src/stores/notificationStore.ts
+++ b/frontend/src/stores/notificationStore.ts
@@ -5,7 +5,7 @@ import {
markAllReadApiV1NotificationsMarkAllReadPost,
deleteNotificationApiV1NotificationsNotificationIdDelete,
type NotificationResponse,
-} from '../lib/api';
+} from '$lib/api';
interface State {
notifications: NotificationResponse[];
@@ -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);
diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts
index 0aeb13e..e9371c0 100644
--- a/frontend/src/stores/theme.ts
+++ b/frontend/src/stores/theme.ts
@@ -23,15 +23,15 @@ 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) {
Promise.all([
- import('../lib/user-settings'),
- import('./auth')
+ import('$lib/user-settings'),
+ import('$stores/auth')
]).then(([userSettings, auth]) => {
- saveThemeSetting = userSettings.saveThemeSetting;
+ saveUserSettings = userSettings.saveUserSettings;
isAuthenticatedStore = auth.isAuthenticated;
});
}
@@ -43,9 +43,6 @@ export const theme = {
if (browser) {
localStorage.setItem(storageKey, value);
}
- if (saveThemeSetting && isAuthenticatedStore && get(isAuthenticatedStore)) {
- saveThemeSetting(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);
-}
diff --git a/frontend/src/stores/userSettings.ts b/frontend/src/stores/userSettings.ts
new file mode 100644
index 0000000..2e8ce84
--- /dev/null
+++ b/frontend/src/stores/userSettings.ts
@@ -0,0 +1,26 @@
+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 clearUserSettings(): void {
+ userSettings.set(null);
+}
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;
}
}
diff --git a/frontend/src/utils/__tests__/meta.test.ts b/frontend/src/utils/__tests__/meta.test.ts
index 0c30c3f..e7e2a1b 100644
--- a/frontend/src/utils/__tests__/meta.test.ts
+++ b/frontend/src/utils/__tests__/meta.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { updateMetaTags, pageMeta } from '../meta';
+import { updateMetaTags, pageMeta } from '$utils/meta';
describe('meta utilities', () => {
let originalTitle: string;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 6c8b21d..c1458bf 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -16,7 +16,18 @@
"outDir": "./public/build",
"baseUrl": ".",
"paths": {
- "$lib/*": ["src/lib/*"]
+ "$lib": ["src/lib"],
+ "$lib/*": ["src/lib/*"],
+ "$components": ["src/components"],
+ "$components/*": ["src/components/*"],
+ "$stores": ["src/stores"],
+ "$stores/*": ["src/stores/*"],
+ "$routes": ["src/routes"],
+ "$routes/*": ["src/routes/*"],
+ "$utils": ["src/utils"],
+ "$utils/*": ["src/utils/*"],
+ "$styles": ["src/styles"],
+ "$styles/*": ["src/styles/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"],
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 83c5b6e..e72b5e2 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -29,6 +29,11 @@ export default defineConfig({
conditions: ['browser'],
alias: {
$lib: '/src/lib',
+ $components: '/src/components',
+ $stores: '/src/stores',
+ $routes: '/src/routes',
+ $utils: '/src/utils',
+ $styles: '/src/styles',
},
},
});