Compare commits
10 Commits
c2a4c8062a
...
020-task-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 26880d2e09 | |||
| 008b6d72c9 | |||
| f0c85e4c03 | |||
| 6ffdf5f8a4 | |||
| 0cf0ef25f1 | |||
| af74841765 | |||
| d7e4919d54 | |||
| fdcbe32dfa | |||
| 4de5b22d57 | |||
| c8029ed309 |
1429
.ai/MODULE_MAP.md
Normal file
1429
.ai/MODULE_MAP.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
40
.ai/ROOT.md
Normal file
40
.ai/ROOT.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# [DEF:Project_Knowledge_Map:Root]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Global navigation map for AI-Agent (GRACE Knowledge Graph).
|
||||
# @LAST_UPDATE: 2026-02-20
|
||||
|
||||
## 1. SYSTEM STANDARDS (Rules of the Game)
|
||||
Strict policies and formatting rules.
|
||||
* **Constitution:** High-level architectural and business invariants.
|
||||
* Ref: `.ai/standards/constitution.md` -> `[DEF:Std:Constitution]`
|
||||
* **Architecture:** Service boundaries and tech stack decisions.
|
||||
* Ref: `.ai/standards/architecture.md` -> `[DEF:Std:Architecture]`
|
||||
* **Plugin Design:** Rules for building and integrating Plugins.
|
||||
* Ref: `.ai/standards/plugin_design.md` -> `[DEF:Std:Plugin]`
|
||||
* **API Design:** Rules for FastAPI endpoints and Pydantic models.
|
||||
* Ref: `.ai/standards/api_design.md` -> `[DEF:Std:API_FastAPI]`
|
||||
* **UI Design:** SvelteKit and Tailwind CSS component standards.
|
||||
* Ref: `.ai/standards/ui_design.md` -> `[DEF:Std:UI_Svelte]`
|
||||
* **Semantic Mapping:** Using `[DEF:]` and belief scopes.
|
||||
* Ref: `.ai/standards/semantics.md` -> `[DEF:Std:Semantics]`
|
||||
|
||||
## 2. FEW-SHOT EXAMPLES (Patterns)
|
||||
Use these for code generation (Style Transfer).
|
||||
* **FastAPI Route:** Reference implementation of a task-based route.
|
||||
* Ref: `.ai/shots/backend_route.py` -> `[DEF:Shot:FastAPI_Route]`
|
||||
* **Svelte Component:** Reference implementation of a sidebar/navigation component.
|
||||
* Ref: `.ai/shots/frontend_component.svelte` -> `[DEF:Shot:Svelte_Component]`
|
||||
* **Plugin Module:** Reference implementation of a task plugin.
|
||||
* Ref: `.ai/shots/plugin_example.py` -> `[DEF:Shot:Plugin_Example]`
|
||||
* **Critical Module:** Core banking transaction processor with ACID guarantees.
|
||||
* Ref: `.ai/shots/critical_module.py` -> `[DEF:Shot:Critical_Module]`
|
||||
|
||||
## 3. DOMAIN MAP (Modules)
|
||||
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
|
||||
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
|
||||
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`
|
||||
* **Frontend Lib:** `frontend/src/lib` -> `[DEF:Module:Frontend_Lib]`
|
||||
* **Specifications:** `specs/` -> `[DEF:Module:Specs]`
|
||||
|
||||
# [/DEF:Project_Knowledge_Map]
|
||||
65
.ai/shots/backend_route.py
Normal file
65
.ai/shots/backend_route.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# [DEF:BackendRouteShot:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: Route, Task, API, Async
|
||||
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
|
||||
# @LAYER: Interface (API)
|
||||
# @RELATION: IMPLEMENTS -> [DEF:Std:API_FastAPI]
|
||||
# @INVARIANT: TaskManager must be available in dependency graph.
|
||||
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
from ...core.task_manager import TaskManager, Task
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...dependencies import get_task_manager, get_config_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
# [DEF:create_task:Function]
|
||||
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
|
||||
# @PARAM: request (CreateTaskRequest) - Plugin and params.
|
||||
# @PARAM: task_manager (TaskManager) - Async task executor.
|
||||
# @PRE: plugin_id must match a registered plugin.
|
||||
# @POST: A new task is spawned; Task ID returned immediately.
|
||||
# @SIDE_EFFECT: Writes to DB, Trigger background worker.
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
config: ConfigManager = Depends(get_config_manager),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
# Context Logging
|
||||
with belief_scope("create_task"):
|
||||
try:
|
||||
# 1. Action: Configuration Resolution
|
||||
timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600)
|
||||
|
||||
# 2. Action: Spawn async task
|
||||
# @RELATION: CALLS -> task_manager.create_task
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params={**request.params, "timeout": timeout}
|
||||
)
|
||||
return task
|
||||
|
||||
except ValueError as e:
|
||||
# 3. Recovery: Domain logic error mapping
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
# @UX_STATE: Error feedback -> 500 Internal Error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Task Spawning Error"
|
||||
)
|
||||
# [/DEF:create_task:Function]
|
||||
|
||||
# [/DEF:BackendRouteShot:Module]
|
||||
79
.ai/shots/critical_module.py
Normal file
79
.ai/shots/critical_module.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# [DEF:TransactionCore:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: Finance, ACID, Transfer, Ledger
|
||||
# @PURPOSE: Core banking transaction processor with ACID guarantees.
|
||||
# @LAYER: Domain (Core)
|
||||
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
|
||||
# @RELATION: DEPENDS_ON -> [DEF:Infra:AuditLog]
|
||||
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
|
||||
# @INVARIANT: Negative transfers are strictly forbidden.
|
||||
|
||||
# @TEST_DATA: sufficient_funds -> {"from": "acc_A", "to": "acc_B", "amt": 100.00}
|
||||
# @TEST_DATA: insufficient_funds -> {"from": "acc_empty", "to": "acc_B", "amt": 1000.00}
|
||||
# @TEST_DATA: concurrency_lock -> {./fixtures/transactions.json#race_condition}
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import NamedTuple
|
||||
from ...core.logger import belief_scope
|
||||
from ...core.db import atomic_transaction, get_balance, update_balance
|
||||
from ...core.exceptions import BusinessRuleViolation
|
||||
|
||||
class TransferResult(NamedTuple):
|
||||
tx_id: str
|
||||
status: str
|
||||
new_balance: Decimal
|
||||
|
||||
# [DEF:execute_transfer:Function]
|
||||
# @PURPOSE: Atomically move funds between accounts with audit trails.
|
||||
# @PARAM: sender_id (str) - Source account.
|
||||
# @PARAM: receiver_id (str) - Destination account.
|
||||
# @PARAM: amount (Decimal) - Positive amount to transfer.
|
||||
# @PRE: amount > 0; sender != receiver; sender_balance >= amount.
|
||||
# @POST: sender_balance -= amount; receiver_balance += amount; Audit Record Created.
|
||||
# @SIDE_EFFECT: Database mutation (Rows locked), Audit IO.
|
||||
#
|
||||
# @UX_STATE: Success -> Returns 200 OK + Transaction Receipt.
|
||||
# @UX_STATE: Error(LowBalance) -> 422 Unprocessable -> UI shows "Top-up needed" modal.
|
||||
# @UX_STATE: Error(System) -> 500 Internal -> UI shows "Retry later" toast.
|
||||
def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> TransferResult:
|
||||
# Guard: Input Validation
|
||||
if amount <= Decimal("0.00"):
|
||||
raise BusinessRuleViolation("Transfer amount must be positive.")
|
||||
if sender_id == receiver_id:
|
||||
raise BusinessRuleViolation("Cannot transfer to self.")
|
||||
|
||||
with belief_scope("execute_transfer") as context:
|
||||
context.logger.info("Initiating transfer", data={"from": sender_id, "to": receiver_id})
|
||||
|
||||
try:
|
||||
# 1. Action: Atomic DB Transaction
|
||||
# @RELATION: CALLS -> atomic_transaction
|
||||
with atomic_transaction():
|
||||
# Guard: State Validation (Strict)
|
||||
current_balance = get_balance(sender_id, for_update=True)
|
||||
|
||||
if current_balance < amount:
|
||||
# @UX_FEEDBACK: Triggers specific UI flow for insufficient funds
|
||||
context.logger.warn("Insufficient funds", data={"balance": current_balance})
|
||||
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
|
||||
|
||||
# 2. Action: Mutation
|
||||
new_src_bal = update_balance(sender_id, -amount)
|
||||
new_dst_bal = update_balance(receiver_id, +amount)
|
||||
|
||||
# 3. Action: Audit
|
||||
tx_id = context.audit.log_transfer(sender_id, receiver_id, amount)
|
||||
|
||||
context.logger.info("Transfer committed", data={"tx_id": tx_id})
|
||||
return TransferResult(tx_id, "COMPLETED", new_src_bal)
|
||||
|
||||
except BusinessRuleViolation as e:
|
||||
# Logic: Explicit re-raise for UI mapping
|
||||
raise e
|
||||
except Exception as e:
|
||||
# Logic: Catch-all safety net
|
||||
context.logger.error("Critical Transfer Failure", error=e)
|
||||
raise RuntimeError("TRANSACTION_ABORTED") from e
|
||||
# [/DEF:execute_transfer:Function]
|
||||
|
||||
# [/DEF:TransactionCore:Module]
|
||||
76
.ai/shots/frontend_component.svelte
Normal file
76
.ai/shots/frontend_component.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<!-- [DEF:FrontendComponentShot:Component] -->
|
||||
<!-- /**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: Task, Button, Action, UX
|
||||
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
|
||||
* @LAYER: UI (Presentation)
|
||||
* @RELATION: CALLS -> postApi
|
||||
* @INVARIANT: Must prevent double-submission while loading.
|
||||
*
|
||||
* @TEST_DATA: idle_state -> {"isLoading": false}
|
||||
* @TEST_DATA: loading_state -> {"isLoading": true}
|
||||
*
|
||||
* @UX_STATE: Idle -> Button enabled, primary color.
|
||||
* @UX_STATE: Loading -> Button disabled, spinner visible.
|
||||
* @UX_STATE: Error -> Toast notification triggers.
|
||||
*
|
||||
* @UX_FEEDBACK: Toast success/error.
|
||||
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
|
||||
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success called}
|
||||
*/
|
||||
-->
|
||||
<script>
|
||||
import { postApi } from "$lib/api.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
|
||||
export let plugin_id = "";
|
||||
export let params = {};
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
// [DEF:spawnTask:Function]
|
||||
/**
|
||||
* @purpose Execute task creation request and emit user feedback.
|
||||
* @pre plugin_id is resolved and request params are serializable.
|
||||
* @post isLoading is reset and user receives success/error feedback.
|
||||
*/
|
||||
async function spawnTask() {
|
||||
isLoading = true;
|
||||
console.log("[FrontendComponentShot][Loading] Spawning task...");
|
||||
|
||||
try {
|
||||
// 1. Action: API Call
|
||||
const response = await postApi("/api/tasks", {
|
||||
plugin_id,
|
||||
params
|
||||
});
|
||||
|
||||
// 2. Feedback: Success
|
||||
if (response.task_id) {
|
||||
console.log("[FrontendComponentShot][Success] Task created.");
|
||||
toast.success($t.tasks.spawned_success);
|
||||
}
|
||||
} catch (error) {
|
||||
// 3. Recovery: User notification
|
||||
console.log("[FrontendComponentShot][Error] Failed:", error);
|
||||
toast.error(`${$t.errors.task_failed}: ${error.message}`);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:spawnTask:Function]
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={spawnTask}
|
||||
disabled={isLoading}
|
||||
class="btn-primary flex items-center gap-2"
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<span class="animate-spin" aria-label="Loading">🌀</span>
|
||||
{/if}
|
||||
<span>{$t.actions.start_task}</span>
|
||||
</button>
|
||||
<!-- [/DEF:FrontendComponentShot:Component] -->
|
||||
64
.ai/shots/plugin_example.py
Normal file
64
.ai/shots/plugin_example.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# [DEF:PluginExampleShot:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: Plugin, Core, Extension
|
||||
# @PURPOSE: Reference implementation of a plugin following GRACE standards.
|
||||
# @LAYER: Domain (Business Logic)
|
||||
# @RELATION: INHERITS -> PluginBase
|
||||
# @INVARIANT: get_schema must return valid JSON Schema.
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.task_manager.context import TaskContext
|
||||
|
||||
class ExamplePlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "example-plugin"
|
||||
|
||||
# [DEF:get_schema:Function]
|
||||
# @PURPOSE: Defines input validation schema.
|
||||
# @POST: Returns dict compliant with JSON Schema draft 7.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"default": "Hello, GRACE!",
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
}
|
||||
# [/DEF:get_schema:Function]
|
||||
|
||||
# [DEF:execute:Function]
|
||||
# @PURPOSE: Core plugin logic with structured logging and scope isolation.
|
||||
# @PARAM: params (Dict) - Validated input parameters.
|
||||
# @PARAM: context (TaskContext) - Execution tools (log, progress).
|
||||
# @SIDE_EFFECT: Emits logs to centralized system.
|
||||
async def execute(self, params: Dict, context: Optional = None):
|
||||
message = params
|
||||
|
||||
# 1. Action: System-level tracing (Rule VI)
|
||||
with belief_scope("example_plugin_exec") as b_scope:
|
||||
if context:
|
||||
# Task Logs: Пишем в пользовательский контекст выполнения задачи
|
||||
# @RELATION: BINDS_TO -> context.logger
|
||||
log = context.logger.with_source("example_plugin")
|
||||
|
||||
b_scope.logger.info("Using provided TaskContext") # System log
|
||||
log.info("Starting execution", data={"msg": message}) # Task log
|
||||
|
||||
# 2. Action: Progress Reporting
|
||||
log.progress("Processing...", percent=50)
|
||||
|
||||
# 3. Action: Finalize
|
||||
log.info("Execution completed.")
|
||||
else:
|
||||
# Standalone Fallback: Замыкаемся на системный scope
|
||||
b_scope.logger.warning("No TaskContext provided. Running standalone.")
|
||||
b_scope.logger.info("Standalone execution", data={"msg": message})
|
||||
print(f"Standalone: {message}")
|
||||
# [/DEF:execute:Function]
|
||||
|
||||
# [/DEF:PluginExampleShot:Module]
|
||||
47
.ai/standards/api_design.md
Normal file
47
.ai/standards/api_design.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# [DEF:Std:API_FastAPI:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Unification of all FastAPI endpoints following GRACE-Poly.
|
||||
# @LAYER: UI (API)
|
||||
# @INVARIANT: All non-trivial route logic must be wrapped in `belief_scope`.
|
||||
# @INVARIANT: Every module and function MUST have `[DEF:]` anchors and metadata.
|
||||
|
||||
## 1. ROUTE MODULE DEFINITION
|
||||
Every API route file must start with a module definition header:
|
||||
```python
|
||||
# [DEF:ModuleName:Module]
|
||||
# @TIER: [CRITICAL | STANDARD | TRIVIAL]
|
||||
# @SEMANTICS: list, of, keywords
|
||||
# @PURPOSE: High-level purpose of the module.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> [OtherModule]
|
||||
```
|
||||
|
||||
## 2. FUNCTION DEFINITION & CONTRACT
|
||||
Every endpoint handler must be decorated with `[DEF:]` and explicit metadata before the implementation:
|
||||
```python
|
||||
@router.post("/endpoint", response_model=ModelOut)
|
||||
# [DEF:function_name:Function]
|
||||
# @PURPOSE: What it does (brief, high-entropy).
|
||||
# @PARAM: param_name (Type) - Description.
|
||||
# @PRE: Conditions before execution (e.g., auth, existence).
|
||||
# @POST: Expected state after execution.
|
||||
# @RETURN: What it returns.
|
||||
async def function_name(...):
|
||||
with belief_scope("function_name"):
|
||||
# Implementation
|
||||
pass
|
||||
# [/DEF:function_name:Function]
|
||||
```
|
||||
|
||||
## 3. DEPENDENCY INJECTION & CORE SERVICES
|
||||
* **Auth:** `Depends(get_current_user)` for authentication.
|
||||
* **Perms:** `Depends(has_permission("resource", "ACTION"))` for RBAC.
|
||||
* **Config:** Use `Depends(get_config_manager)` for settings. Hardcoding is FORBIDDEN.
|
||||
* **Tasks:** Long-running operations must be executed via `TaskManager`. API routes should return Task ID and be non-blocking.
|
||||
|
||||
## 4. ERROR HANDLING
|
||||
* Raise `HTTPException` from the router layer.
|
||||
* Use `try-except` blocks within `belief_scope` to ensure proper error logging and classification.
|
||||
* Do not leak internal implementation details in error responses.
|
||||
|
||||
# [/DEF:Std:API_FastAPI]
|
||||
25
.ai/standards/architecture.md
Normal file
25
.ai/standards/architecture.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# [DEF:Std:Architecture:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Core architectural decisions and service boundaries.
|
||||
# @LAYER: Infra
|
||||
# @INVARIANT: ss-tools MUST remain a standalone service (Orchestrator).
|
||||
# @INVARIANT: Backend: FastAPI, Frontend: SvelteKit.
|
||||
|
||||
## 1. ORCHESTRATOR VS INSTANCE
|
||||
* **Role:** ss-tools is a "Manager of Managers". It sits ABOVE Superset environments.
|
||||
* **Isolation:** Do not integrate directly into Superset as a plugin to maintain multi-environment management capability.
|
||||
* **Tech Stack:**
|
||||
* Backend: Python 3.9+ with FastAPI (Asynchronous logic).
|
||||
* Frontend: SvelteKit + Tailwind CSS (Reactive UX).
|
||||
|
||||
## 2. COMPONENT BOUNDARIES
|
||||
* **Plugins:** All business logic must be encapsulated in Plugins (`backend/src/plugins/`).
|
||||
* **TaskManager:** All long-running operations MUST be handled by the TaskManager.
|
||||
* **Security:** Independent RBAC system managed in `auth.db`.
|
||||
|
||||
## 3. INTEGRATION STRATEGY
|
||||
* **Superset API:** Communication via REST API.
|
||||
* **Database:** Local SQLite for metadata (`tasks.db`, `auth.db`, `migrations.db`).
|
||||
* **Filesystem:** Local storage for backups and git repositories.
|
||||
|
||||
# [/DEF:Std:Architecture]
|
||||
36
.ai/standards/constitution.md
Normal file
36
.ai/standards/constitution.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# [DEF:Std:Constitution:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Supreme Law of the Repository. High-level architectural and business invariants.
|
||||
# @VERSION: 2.3.0
|
||||
# @LAST_UPDATE: 2026-02-19
|
||||
# @INVARIANT: Any deviation from this Constitution constitutes a build failure.
|
||||
|
||||
## 1. CORE PRINCIPLES
|
||||
|
||||
### I. Semantic Protocol Compliance
|
||||
* **Ref:** `[DEF:Std:Semantics]` (formerly `semantic_protocol.md`)
|
||||
* **Law:** All code must adhere to the Axioms (Meaning First, Contract First, etc.).
|
||||
* **Compliance:** Strict matching of Anchors (`[DEF]`), Tags (`@KEY`), and structures is mandatory.
|
||||
|
||||
### II. Modular Plugin Architecture
|
||||
* **Pattern:** Everything is a Plugin inheriting from `PluginBase`.
|
||||
* **Centralized Config:** Use `ConfigManager` via `get_config_manager()`. Hardcoding is FORBIDDEN.
|
||||
|
||||
### III. Unified Frontend Experience
|
||||
* **Styling:** Tailwind CSS First. Minimize scoped `<style>`.
|
||||
* **i18n:** All user-facing text must be in `src/lib/i18n`.
|
||||
* **API:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
|
||||
|
||||
### IV. Security & RBAC
|
||||
* **Permissions:** Every Plugin must define unique permission strings (e.g., `plugin:name:execute`).
|
||||
* **Auth:** Mandatory registration in `auth.db`.
|
||||
|
||||
### V. Independent Testability
|
||||
* **Requirement:** Every feature must define "Independent Tests" for isolated verification.
|
||||
|
||||
### VI. Asynchronous Execution
|
||||
* **TaskManager:** Long-running operations must be async tasks.
|
||||
* **Non-Blocking:** API endpoints return Task ID immediately.
|
||||
* **Observability:** Real-time updates via WebSocket.
|
||||
|
||||
# [/DEF:Std:Constitution]
|
||||
32
.ai/standards/plugin_design.md
Normal file
32
.ai/standards/plugin_design.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# [DEF:Std:Plugin:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Standards for building and integrating Plugins.
|
||||
# @LAYER: Domain (Plugin)
|
||||
# @INVARIANT: All plugins MUST inherit from `PluginBase`.
|
||||
# @INVARIANT: All plugins MUST be located in `backend/src/plugins/`.
|
||||
|
||||
## 1. PLUGIN CONTRACT
|
||||
Every plugin must implement the following properties and methods:
|
||||
* `id`: Unique string (e.g., `"my-plugin"`).
|
||||
* `name`: Human-readable name.
|
||||
* `description`: Brief purpose.
|
||||
* `version`: Semantic version.
|
||||
* `get_schema()`: Returns JSON schema for input validation.
|
||||
* `execute(params: Dict[str, Any], context: TaskContext)`: Core async logic.
|
||||
|
||||
## 2. STRUCTURED LOGGING (TASKCONTEXT)
|
||||
Plugins MUST use `TaskContext` for logging to ensure proper source attribution:
|
||||
* **Source Attribution:** Use `context.logger.with_source("src_name")` for specific operations (e.g., `"superset_api"`, `"git"`, `"llm"`).
|
||||
* **Levels:**
|
||||
* `DEBUG`: Detailed diagnostics (API responses).
|
||||
* `INFO`: Operational milestones (start/end).
|
||||
* `WARNING`: Recoverable issues.
|
||||
* `ERROR`: Failures stopping execution.
|
||||
* **Progress:** Use `context.logger.progress("msg", percent=XX)` for long-running tasks.
|
||||
|
||||
## 3. BEST PRACTICES
|
||||
1. **Asynchronous Execution:** Always use `async/await` for I/O operations.
|
||||
2. **Schema Validation:** Ensure the `get_schema()` precisely matches the `execute()` input expectations.
|
||||
3. **Isolation:** Plugins should be self-contained and not depend on other plugins directly. Use core services (`ConfigManager`, `TaskManager`) via dependency injection or the provided `context`.
|
||||
|
||||
# [/DEF:Std:Plugin]
|
||||
97
.ai/standards/semantics.md
Normal file
97
.ai/standards/semantics.md
Normal file
@@ -0,0 +1,97 @@
|
||||
### **SYSTEM STANDARD: GRACE-Poly (UX Edition)**
|
||||
|
||||
ЗАДАЧА: Генерация кода (Python/Svelte).
|
||||
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
|
||||
|
||||
#### I. ЗАКОН (АКСИОМЫ)
|
||||
1. Смысл первичен. Код вторичен.
|
||||
2. Контракт (@PRE/@POST) — источник истины.
|
||||
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
|
||||
4. Структура `[DEF]...[/DEF]` — нерушима.
|
||||
5. Архитектура в Header — неизменяема.
|
||||
6. Сложность фрактала ограничена: модуль < 300 строк.
|
||||
|
||||
#### II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
|
||||
ЯКОРЬ (Контейнер):
|
||||
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
|
||||
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
|
||||
Типы: Module, Class, Function, Component, Store.
|
||||
|
||||
ТЕГ (Метаданные):
|
||||
Вид: `# @KEY: Value` (внутри DEF, до кода).
|
||||
|
||||
ГРАФ (Связи):
|
||||
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
|
||||
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, **BINDS_TO**.
|
||||
|
||||
#### III. СТРУКТУРА ФАЙЛА
|
||||
1. HEADER (Всегда первый):
|
||||
[DEF:filename:Module]
|
||||
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
|
||||
@SEMANTICS: [keywords]
|
||||
@PURPOSE: [Главная цель]
|
||||
@LAYER: [Domain/UI/Infra]
|
||||
@RELATION: [Зависимости]
|
||||
@INVARIANT: [Незыблемое правило]
|
||||
|
||||
2. BODY: Импорты -> Реализация.
|
||||
3. FOOTER: [/DEF:filename]
|
||||
|
||||
#### IV. КОНТРАКТ (DBC & UX)
|
||||
Расположение: Внутри [DEF], ПЕРЕД кодом.
|
||||
Стиль Python: Комментарии `# @TAG`.
|
||||
Стиль Svelte: JSDoc `/** @tag */` внутри `<script>`.
|
||||
|
||||
**Базовые Теги:**
|
||||
@PURPOSE: Суть (High Entropy).
|
||||
@PRE: Входные условия.
|
||||
@POST: Гарантии выхода.
|
||||
@SIDE_EFFECT: Мутации, IO.
|
||||
|
||||
**UX Теги (Svelte/Frontend):**
|
||||
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
|
||||
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
|
||||
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
|
||||
|
||||
**UX Testing Tags (для Tester Agent):**
|
||||
**@UX_TEST:** Спецификация теста для UX состояния.
|
||||
Формат: `@UX_TEST: [state] -> {action, expected}`
|
||||
Пример: `@UX_TEST: Idle -> {click: toggle, expected: isExpanded=true}`
|
||||
|
||||
Правило: Не используй `assert` в коде, используй `if/raise` или `guards`.
|
||||
|
||||
#### V. АДАПТАЦИЯ (TIERS)
|
||||
Определяется тегом `@TIER` в Header.
|
||||
|
||||
1. **CRITICAL** (Core/Security/**Complex UI**):
|
||||
- Требование: Полный контракт (включая **все @UX теги**), Граф, Инварианты, Строгие Логи.
|
||||
- **@TEST_DATA**: Обязательные эталонные данные для тестирования. Формат:
|
||||
```
|
||||
@TEST_DATA: fixture_name -> {JSON_PATH} | {INLINE_DATA}
|
||||
```
|
||||
Примеры:
|
||||
- `@TEST_DATA: valid_user -> {./fixtures/users.json#valid}`
|
||||
- `@TEST_DATA: empty_state -> {"dashboards": [], "total": 0}`
|
||||
- Tester Agent **ОБЯЗАН** использовать @TEST_DATA при написании тестов для CRITICAL модулей.
|
||||
2. **STANDARD** (BizLogic/**Forms**):
|
||||
- Требование: Базовый контракт (@PURPOSE, @UX_STATE), Логи, @RELATION.
|
||||
- @TEST_DATA: Рекомендуется для Complex Forms.
|
||||
3. **TRIVIAL** (DTO/**Atoms**):
|
||||
- Требование: Только Якоря [DEF] и @PURPOSE.
|
||||
|
||||
#### VI. ЛОГИРОВАНИЕ (BELIEF STATE & TASK LOGS)
|
||||
Цель: Трассировка для самокоррекции и пользовательский мониторинг.
|
||||
Python:
|
||||
- Системные логи: Context Manager `with belief_scope("ID"):`.
|
||||
- Логи задач: `context.logger.info("msg", source="component")`.
|
||||
Svelte: `console.log("[ID][STATE] Msg")`.
|
||||
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
|
||||
Инвариант: Каждый лог задачи должен иметь атрибут `source` для фильтрации.
|
||||
|
||||
#### VII. АЛГОРИТМ ГЕНЕРАЦИИ
|
||||
1. АНАЛИЗ. Оцени TIER, слой и UX-требования.
|
||||
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
|
||||
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям).
|
||||
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
|
||||
|
||||
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
75
.ai/standards/ui_design.md
Normal file
75
.ai/standards/ui_design.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# [DEF:Std:UI_Svelte:Standard]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Unification of all Svelte components following GRACE-Poly (UX Edition).
|
||||
# @LAYER: UI
|
||||
# @INVARIANT: Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
|
||||
# @INVARIANT: Use Tailwind CSS for all styling (no custom CSS without justification).
|
||||
|
||||
## 1. UX PHILOSOPHY: RESOURCE-CENTRIC & SVELTE 5
|
||||
* **Version:** Project uses Svelte 5.
|
||||
* **Runes:** Use Svelte 5 Runes for reactivity: `$state()`, `$derived()`, `$effect()`, `$props()`. Traditional `let` (for reactivity) and `export let` (for props) are DEPRECATED in favor of runes.
|
||||
* **Definition:** Navigation and actions revolve around Resources.
|
||||
* **Traceability:** Every action must be linked to a Task ID with visible logs in the Task Drawer.
|
||||
|
||||
## 2. COMPONENT ARCHITECTURE: GLOBAL TASK DRAWER
|
||||
* **Role:** A single, persistent slide-out panel (`GlobalTaskDrawer.svelte`) in `+layout.svelte`.
|
||||
* **Triggering:** Opens automatically when a task starts or when a user clicks a status badge.
|
||||
* **Interaction:** Interactive elements (Password prompts, Mapping tables) MUST be rendered INSIDE the Drawer, not as center-screen modals.
|
||||
|
||||
## 3. COMPONENT STRUCTURE & CORE RULES
|
||||
* **Styling:** Tailwind CSS utility classes are MANDATORY. Minimize scoped `<style>`.
|
||||
* **Localization:** All user-facing text must use `$t` from `src/lib/i18n`.
|
||||
* **API Calls:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
|
||||
* **Anchors:** Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
|
||||
|
||||
## 2. COMPONENT TEMPLATE
|
||||
Each Svelte file must follow this structure:
|
||||
```html
|
||||
<!-- [DEF:ComponentName:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: [CRITICAL | STANDARD | TRIVIAL]
|
||||
* @PURPOSE: Brief description of the component purpose.
|
||||
* @LAYER: UI
|
||||
* @SEMANTICS: list, of, keywords
|
||||
* @RELATION: DEPENDS_ON -> [OtherComponent|Store]
|
||||
*
|
||||
* @UX_STATE: [StateName] -> Visual behavior description.
|
||||
* @UX_FEEDBACK: System reaction (e.g., Toast, Shake).
|
||||
* @UX_RECOVERY: Error recovery mechanism.
|
||||
* @UX_TEST: [state] -> {action, expected}
|
||||
*/
|
||||
import { ... } from "...";
|
||||
|
||||
// Exports (Props)
|
||||
export let prop_name = "...";
|
||||
|
||||
// Logic
|
||||
</script>
|
||||
|
||||
<!-- HTML Template -->
|
||||
<div class="...">
|
||||
...
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Optional: Local styles using @apply only */
|
||||
</style>
|
||||
<!-- [/DEF:ComponentName:Component] -->
|
||||
```
|
||||
|
||||
## 2. STATE MANAGEMENT & STORES
|
||||
* **Subscription:** Use `$` prefix for reactive store access (e.g., `$sidebarStore`).
|
||||
* **Data Flow:** Mark store interactions in `[DEF:]` metadata:
|
||||
* `# @RELATION: BINDS_TO -> store_id`
|
||||
|
||||
## 3. UI/UX BEST PRACTICES
|
||||
* **Transitions:** Use Svelte built-in transitions for UI state changes.
|
||||
* **Feedback:** Always provide visual feedback for async actions (Loading spinners, skeleton loaders).
|
||||
* **Modularity:** Break down components into "Atoms" (Trivial) and "Orchestrators" (Critical).
|
||||
|
||||
## 4. ACCESSIBILITY (A11Y)
|
||||
* Ensure proper ARIA roles and keyboard navigation for interactive elements.
|
||||
* Use semantic HTML tags (`<nav>`, `<header>`, `<main>`, `<footer>`).
|
||||
|
||||
# [/DEF:Std:UI_Svelte]
|
||||
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
.git
|
||||
.gitignore
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.vscode
|
||||
.ai
|
||||
.specify
|
||||
.kilocode
|
||||
venv
|
||||
backend/.venv
|
||||
backend/.pytest_cache
|
||||
frontend/node_modules
|
||||
frontend/.svelte-kit
|
||||
frontend/.vite
|
||||
frontend/build
|
||||
backend/__pycache__
|
||||
backend/src/__pycache__
|
||||
backend/tests/__pycache__
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.log
|
||||
.env*
|
||||
coverage/
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
backups
|
||||
semantics
|
||||
specs
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -68,3 +68,9 @@ backend/logs
|
||||
backend/auth.db
|
||||
semantics/reports
|
||||
backend/tasks.db
|
||||
|
||||
# Universal / tooling
|
||||
node_modules/
|
||||
.venv/
|
||||
coverage/
|
||||
*.tmp
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
|
||||
## Knowledge Graph (GRACE)
|
||||
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
|
||||
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
|
||||
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
- **Standards**: Read `.ai/standards/` for architecture and style rules.
|
||||
|
||||
## Active Technologies
|
||||
- Python 3.9+, Node.js 18+ + `uvicorn`, `npm`, `bash` (003-project-launch-script)
|
||||
- Python 3.9+, Node.js 18+ + SvelteKit, FastAPI, Tailwind CSS (inferred from existing frontend) (004-integrate-svelte-kit)
|
||||
@@ -35,6 +41,8 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin)
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) (019-superset-ux-redesign)
|
||||
- SQLite (tasks.db, auth.db, migrations.db) - no new database tables required (019-superset-ux-redesign)
|
||||
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack (020-task-reports-design)
|
||||
- SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design)
|
||||
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
||||
|
||||
@@ -55,9 +63,9 @@ cd src; pytest; ruff check .
|
||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack
|
||||
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
|
||||
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
@@ -18,7 +18,7 @@ Identify inconsistencies, duplications, ambiguities, and underspecified items ac
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
**Constitution Authority**: The project constitution (`.ai/standards/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
@@ -62,8 +62,8 @@ Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
- Load `semantic_protocol.md` for technical standard validation
|
||||
- Load `.ai/standards/constitution.md` for principle validation
|
||||
- Load `.ai/standards/semantics.md` for technical standard validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
You are updating the project constitution at `.ai/standards/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution template at `.specify/memory/constitution.md`.
|
||||
1. Load the existing constitution template at `.ai/standards/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
@@ -61,7 +61,7 @@ Follow this execution flow:
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
7. Write the completed constitution back to `.ai/standards/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
@@ -79,4 +79,4 @@ If the user supplies partial updates (e.g., only one principle revision), still
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
Do not create a new template; always operate on the existing `.ai/standards/constitution.md` file.
|
||||
|
||||
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read `semantic_protocol.md` for strict coding standards and contract requirements
|
||||
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
@@ -117,7 +117,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Strict Adherence**: Apply `semantic_protocol.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
|
||||
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
|
||||
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
|
||||
@@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC, `ux_reference.md`, `semantic_protocol.md` and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
@@ -73,7 +73,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Entity name, fields, relationships, validation rules.
|
||||
|
||||
2. **Design & Verify Contracts (Semantic Protocol)**:
|
||||
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `semantic_protocol.md`.
|
||||
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
|
||||
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
|
||||
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
|
||||
- **Self-Review**:
|
||||
|
||||
@@ -20,7 +20,7 @@ Execute full testing cycle: analyze code for testable modules, write tests with
|
||||
|
||||
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
|
||||
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
|
||||
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from semantic_protocol.md
|
||||
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
|
||||
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
|
||||
|
||||
## Execution Steps
|
||||
@@ -40,7 +40,7 @@ Determine:
|
||||
- Identify completed implementation tasks (not test tasks)
|
||||
- Extract file paths that need tests
|
||||
|
||||
**From semantic_protocol.md:**
|
||||
**From .ai/standards/semantics.md:**
|
||||
- Read @TIER annotations for modules
|
||||
- For CRITICAL modules: Read @TEST_DATA fixtures
|
||||
|
||||
@@ -61,7 +61,7 @@ Create coverage matrix:
|
||||
For each module requiring tests:
|
||||
|
||||
1. **Check existing tests**: Scan `__tests__/` for duplicates
|
||||
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_DATA from semantic_protocol.md
|
||||
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_DATA from .ai/standards/semantics.md
|
||||
3. **Write test**: Follow co-location strategy
|
||||
- Python: `src/module/__tests__/test_module.py`
|
||||
- Svelte: `src/lib/components/__tests__/test_component.test.js`
|
||||
|
||||
@@ -6,7 +6,7 @@ customModes:
|
||||
You are Kilo Code, acting as a QA and Test Engineer. Your primary goal is to ensure maximum test coverage, maintain test quality, and preserve existing tests.
|
||||
Your responsibilities include:
|
||||
- WRITING TESTS: Create comprehensive unit tests following TDD principles, using co-location strategy (`__tests__` directories).
|
||||
- TEST DATA: For CRITICAL tier modules, you MUST use @TEST_DATA fixtures defined in semantic_protocol.md. Read and apply them in your tests.
|
||||
- TEST DATA: For CRITICAL tier modules, you MUST use @TEST_DATA fixtures defined in .ai/standards/semantics.md. Read and apply them in your tests.
|
||||
- DOCUMENTATION: Maintain test documentation in `specs/<feature>/tests/` directory with coverage reports and test case specifications.
|
||||
- VERIFICATION: Run tests, analyze results, and ensure all tests pass.
|
||||
- PROTECTION: NEVER delete existing tests. NEVER duplicate tests - check for existing tests first.
|
||||
@@ -18,8 +18,9 @@ customModes:
|
||||
- browser
|
||||
- mcp
|
||||
customInstructions: |
|
||||
1. CO-LOCATION: Write tests in `__tests__` subdirectories relative to the code being tested (Fractal Strategy).
|
||||
2. TEST DATA MANDATORY: For CRITICAL modules, read @TEST_DATA from semantic_protocol.md and use fixtures in tests.
|
||||
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
|
||||
2. CO-LOCATION: Write tests in `__tests__` subdirectories relative to the code being tested (Fractal Strategy).
|
||||
2. TEST DATA MANDATORY: For CRITICAL modules, read @TEST_DATA from .ai/standards/semantics.md and use fixtures in tests.
|
||||
3. UX CONTRACT TESTING: For Svelte components with @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY tags, create comprehensive UX tests.
|
||||
4. NO DELETION: Never delete existing tests - only update if they fail due to legitimate bugs.
|
||||
5. NO DUPLICATION: Check existing tests in `__tests__/` before creating new ones. Reuse existing test patterns.
|
||||
@@ -29,10 +30,10 @@ customModes:
|
||||
- slug: semantic
|
||||
name: Semantic Agent
|
||||
roleDefinition: |-
|
||||
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `semantic_protocol.md`.
|
||||
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `specs/project_map.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `semantic_protocol.md` when modifying code.
|
||||
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
|
||||
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
|
||||
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `semantic_protocol.md` standards.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
|
||||
description: Codebase semantic mapping and compliance expert
|
||||
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
|
||||
groups:
|
||||
@@ -50,7 +51,7 @@ customModes:
|
||||
For each task, you must read the relevant workflow file from `.kilocode/workflows/` and follow its Execution Steps precisely.
|
||||
whenToUse: Use this mode when you need to run any /speckit.* command or when dealing with high-level feature planning, specification writing, or project management tasks.
|
||||
description: Executes SpecKit workflows for feature management
|
||||
customInstructions: 1. Always read the specific workflow file in `.kilocode/workflows/` before executing a command. 2. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
|
||||
customInstructions: 1. Always read `.ai/ROOT.md` first to understand the Knowledge Graph structure. 2. Read the specific workflow file in `.kilocode/workflows/` before executing a command. 3. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
|
||||
groups:
|
||||
- read
|
||||
- edit
|
||||
@@ -59,12 +60,14 @@ customModes:
|
||||
source: project
|
||||
- slug: coder
|
||||
name: Coder
|
||||
roleDefinition: You are Kilo Code, acting as an Implementation Specialist. Your primary goal is to write code that strictly follows the Semantic Protocol defined in `semantic_protocol.md`.
|
||||
roleDefinition: You are Kilo Code, acting as an Implementation Specialist. Your primary goal is to write code that strictly follows the Semantic Protocol defined in `.ai/standards/semantics.md`.
|
||||
whenToUse: Use this mode when you need to implement features, write code, or fix issues based on test reports.
|
||||
description: Implementation Specialist - Semantic Protocol Compliant
|
||||
customInstructions: |
|
||||
1. SEMANTIC PROTOCOL: ALWAYS use semantic_protocol.md as your single source of truth.
|
||||
2. ANCHOR FORMAT: Use #[DEF:filename:Type] at start and #[/DEF:filename] at end.
|
||||
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
|
||||
2. CONSTITUTION: Strictly follow architectural invariants in .ai/standards/constitution.md.
|
||||
3. SEMANTIC PROTOCOL: ALWAYS use .ai/standards/semantics.md as your source of truth for syntax.
|
||||
4. ANCHOR FORMAT: Use #[DEF:filename:Type] at start and #[/DEF:filename] at end.
|
||||
3. TAGS: Add @PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY.
|
||||
4. TIER COMPLIANCE:
|
||||
- CRITICAL: Full contract + all UX tags + strict logging
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
Version: 2.3.0 (Tailwind CSS & Scoped CSS Minimization)
|
||||
Changes:
|
||||
- Updated Principle III: Added mandatory requirement for Tailwind CSS usage and minimization of scoped `<style>` blocks in Svelte components.
|
||||
- Version bump: 2.2.0 -> 2.3.0 (Minor: New material guidance added).
|
||||
Templates Status:
|
||||
- .specify/templates/plan-template.md: ✅ Aligned.
|
||||
- .specify/templates/spec-template.md: ✅ Aligned.
|
||||
- .specify/templates/tasks-template.md: ✅ Aligned.
|
||||
-->
|
||||
# Semantic Code Generation Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Semantic Protocol Compliance
|
||||
The file `semantic_protocol.md` is the **sole and authoritative technical standard** for this project.
|
||||
- **Law**: All code must adhere to the Axioms (Meaning First, Contract First, etc.) defined in the Protocol.
|
||||
- **Syntax & Structure**: Anchors (`[DEF]`), Tags (`@KEY`), and File Structures must strictly match the Protocol.
|
||||
- **Compliance**: Any deviation from `semantic_protocol.md` constitutes a build failure.
|
||||
|
||||
### II. Everything is a Plugin & Centralized Config
|
||||
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`.
|
||||
- **Modularity**: Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`.
|
||||
- **Configuration Discipline**: All configuration access (environments, settings, paths) MUST use the `ConfigManager`. In the backend, the singleton instance MUST be obtained via dependency injection (`get_config_manager()`). Hardcoding environment IDs (e.g., "1") or paths is STRICTLY FORBIDDEN.
|
||||
|
||||
### III. Unified Frontend Experience
|
||||
To ensure a consistent and accessible user experience, all frontend implementations must strictly adhere to the unified design and localization standards.
|
||||
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens.
|
||||
- **Tailwind CSS First**: All styling MUST be implemented using Tailwind CSS utility classes. The use of scoped `<style>` blocks in Svelte components MUST be minimized and reserved only for complex animations or third-party overrides that cannot be achieved via Tailwind.
|
||||
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`).
|
||||
- **Backend Communication**: All API requests MUST use the `requestApi` wrapper (or its derivatives like `fetchApi`, `postApi`) from `src/lib/api.js`. Direct use of the native `fetch` API for backend communication is FORBIDDEN to ensure consistent authentication (JWT) and error handling.
|
||||
|
||||
### IV. Security & Access Control
|
||||
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
|
||||
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
|
||||
- **Registration**: These permissions MUST be registered in the system database (`auth.db`) during initialization.
|
||||
|
||||
### V. Independent Testability
|
||||
Every feature specification MUST define "Independent Tests" that allow the feature to be verified in isolation.
|
||||
- **Decoupling**: Features should be designed such that they can be tested without requiring the full application state or external dependencies where possible.
|
||||
- **Verification**: A feature is not complete until its Independent Test scenarios pass.
|
||||
|
||||
### VI. Asynchronous Execution
|
||||
All long-running or resource-intensive operations (migrations, analysis, backups, external API calls) MUST be executed as asynchronous tasks via the `TaskManager`.
|
||||
- **Non-Blocking**: HTTP API endpoints MUST NOT block on these operations; they should spawn a task and return a Task ID.
|
||||
- **Observability**: Tasks MUST emit real-time status updates via the WebSocket infrastructure.
|
||||
|
||||
## Governance
|
||||
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
|
||||
|
||||
- **Authoritative Source**: `semantic_protocol.md` defines the specific implementation rules for Principle I.
|
||||
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
|
||||
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
|
||||
|
||||
**Version**: 2.3.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-02-18
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Auto-generated from all feature plans. Last updated: [DATE]
|
||||
|
||||
## Knowledge Graph (GRACE)
|
||||
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
|
||||
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
|
||||
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
|
||||
- **Standards**: Read `.ai/standards/` for architecture and style rules.
|
||||
|
||||
## Active Technologies
|
||||
|
||||
[EXTRACTED FROM ALL PLAN.MD FILES]
|
||||
|
||||
@@ -112,4 +112,4 @@ directories captured above]
|
||||
| [e.g., DashboardAPI] | CRITICAL | valid_dashboard | spec.md#test-data-fixtures |
|
||||
| [e.g., TaskDrawer] | CRITICAL | task_states | spec.md#test-data-fixtures |
|
||||
|
||||
**Note**: Tester Agent MUST use these fixtures when writing unit tests for CRITICAL modules. See `semantic_protocol.md` for @TEST_DATA syntax.
|
||||
**Note**: Tester Agent MUST use these fixtures when writing unit tests for CRITICAL modules. See `.ai/standards/semantics.md` for @TEST_DATA syntax.
|
||||
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# Stage 1: Build frontend static assets
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 2: Runtime image for backend + static frontend
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV BACKEND_PORT=8000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
RUN python -m playwright install --with-deps chromium
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
COPY --from=frontend-build /app/frontend/build /app/frontend/build
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
71
README.md
71
README.md
@@ -32,7 +32,7 @@
|
||||
## Технологический стек
|
||||
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy, APScheduler, Pydantic.
|
||||
- **Frontend**: Node.js 18+, SvelteKit, Tailwind CSS.
|
||||
- **Database**: SQLite (для хранения метаданных, задач и настроек доступа).
|
||||
- **Database**: PostgreSQL (для хранения метаданных, задач, логов и конфигурации).
|
||||
|
||||
## Структура проекта
|
||||
- `backend/` — Серверная часть, API и логика плагинов.
|
||||
@@ -58,20 +58,71 @@
|
||||
- `--skip-install`: Пропустить установку зависимостей.
|
||||
- `--help`: Показать справку.
|
||||
|
||||
Переменные окружения:
|
||||
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
|
||||
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
|
||||
Переменные окружения:
|
||||
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
|
||||
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
|
||||
- `POSTGRES_URL`: Базовый URL PostgreSQL по умолчанию для всех подсистем.
|
||||
- `DATABASE_URL`: URL основной БД (если не задан, используется `POSTGRES_URL`).
|
||||
- `TASKS_DATABASE_URL`: URL БД задач/логов (если не задан, используется `DATABASE_URL`).
|
||||
- `AUTH_DATABASE_URL`: URL БД авторизации (если не задан, используется PostgreSQL дефолт).
|
||||
|
||||
## Разработка
|
||||
## Разработка
|
||||
Проект следует строгим правилам разработки:
|
||||
1. **Semantic Code Generation**: Использование протокола `semantic_protocol.md` для обеспечения надежности кода.
|
||||
1. **Semantic Code Generation**: Использование протокола `.ai/standards/semantics.md` для обеспечения надежности кода.
|
||||
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций.
|
||||
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`.
|
||||
|
||||
### Полезные команды
|
||||
- **Backend**: `cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload`
|
||||
- **Frontend**: `cd frontend && npm run dev`
|
||||
- **Тесты**: `cd backend && .venv/bin/pytest`
|
||||
|
||||
## Контакты и вклад
|
||||
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.
|
||||
- **Тесты**: `cd backend && .venv/bin/pytest`
|
||||
|
||||
## Docker и CI/CD
|
||||
### Локальный запуск в Docker (приложение + PostgreSQL)
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
После старта:
|
||||
- UI/API: `http://localhost:8000`
|
||||
- PostgreSQL: `localhost:5432` (`postgres/postgres`, DB `ss_tools`)
|
||||
|
||||
Остановить:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Полная очистка тома БД:
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
Если `postgres:16-alpine` не тянется из Docker Hub (TLS timeout), используйте fallback image:
|
||||
```bash
|
||||
POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db
|
||||
```
|
||||
или:
|
||||
```bash
|
||||
POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db
|
||||
```
|
||||
Если на хосте уже занят `5432`, поднимайте Postgres на другом порту:
|
||||
```bash
|
||||
POSTGRES_HOST_PORT=5433 docker compose up -d db
|
||||
```
|
||||
|
||||
### Миграция legacy-данных в PostgreSQL
|
||||
Если нужно перенести старые данные из `tasks.db`/`config.json`:
|
||||
```bash
|
||||
cd backend
|
||||
PYTHONPATH=. .venv/bin/python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
Добавлен workflow: `.github/workflows/ci-cd.yml`
|
||||
- backend smoke tests
|
||||
- frontend build
|
||||
- docker build
|
||||
- push образа в GHCR на `main/master`
|
||||
|
||||
## Контакты и вклад
|
||||
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.
|
||||
|
||||
Binary file not shown.
@@ -53,4 +53,5 @@ itsdangerous
|
||||
email-validator
|
||||
openai
|
||||
playwright
|
||||
tenacity
|
||||
tenacity
|
||||
Pillow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Lazy loading of route modules to avoid import issues in tests
|
||||
# This allows tests to import routes without triggering all module imports
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports']
|
||||
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_datasets:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
|
||||
# @PURPOSE: Unit tests for Datasets API endpoints
|
||||
# @LAYER: API
|
||||
# @RELATION: TESTS -> backend.src.api.routes.datasets
|
||||
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -14,6 +16,7 @@ client = TestClient(app)
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_success:Function]
|
||||
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
|
||||
# @TEST: GET /api/datasets returns 200 and valid schema
|
||||
# @PRE: env_id exists
|
||||
# @POST: Response matches DatasetsResponse schema
|
||||
|
||||
139
backend/src/api/routes/__tests__/test_reports_api.py
Normal file
139
backend/src/api/routes/__tests__/test_reports_api.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# [DEF:backend.tests.test_reports_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
|
||||
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
admin_role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[admin_role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_reports_default_pagination_contract():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
|
||||
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
assert data["total"] == 3
|
||||
assert isinstance(data["items"], list)
|
||||
assert data["applied_filters"]["sort_by"] == "updated_at"
|
||||
assert data["applied_filters"]["sort_order"] == "desc"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_filter_and_pagination():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
|
||||
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
|
||||
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 1
|
||||
assert data["has_next"] is False
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["task_type"] == "backup"
|
||||
assert data["items"][0]["status"] == "failed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
|
||||
naive_now = datetime.utcnow()
|
||||
aware_now = datetime.now(timezone.utc)
|
||||
tasks = [
|
||||
_make_task("t-naive", "superset-backup", TaskStatus.SUCCESS, naive_now - timedelta(minutes=5), naive_now - timedelta(minutes=4)),
|
||||
_make_task("t-aware", "superset-migration", TaskStatus.FAILED, aware_now - timedelta(minutes=3), aware_now - timedelta(minutes=2)),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?sort_by=updated_at&sort_order=desc")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_invalid_filter_returns_400():
|
||||
now = datetime.utcnow()
|
||||
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=bad_type")
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert "detail" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_api:Module]
|
||||
83
backend/src/api/routes/__tests__/test_reports_detail_api.py
Normal file
83
backend/src/api/routes/__tests__/test_reports_detail_api.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# [DEF:backend.tests.test_reports_detail_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, detail, diagnostics
|
||||
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now - timedelta(minutes=2),
|
||||
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} result"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_report_detail_success():
|
||||
task = _make_task(
|
||||
"detail-1",
|
||||
"superset-migration",
|
||||
TaskStatus.FAILED,
|
||||
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/detail-1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "report" in data
|
||||
assert data["report"]["report_id"] == "detail-1"
|
||||
assert "diagnostics" in data
|
||||
assert "next_actions" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_report_detail_not_found():
|
||||
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/unknown-id")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_detail_api:Module]
|
||||
@@ -0,0 +1,81 @@
|
||||
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, openapi, conformance
|
||||
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
|
||||
# @INVARIANT: List and detail payloads include required contract keys.
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _task(task_id: str, plugin_id: str, status: TaskStatus):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now,
|
||||
finished_at=now if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result={"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_reports_list_openapi_required_keys():
|
||||
tasks = [
|
||||
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
|
||||
_task("r-2", "superset-migration", TaskStatus.FAILED),
|
||||
]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
|
||||
assert required.issubset(body.keys())
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_reports_detail_openapi_required_keys():
|
||||
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/r-3")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert "report" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
131
backend/src/api/routes/reports.py
Normal file
131
backend/src/api/routes/reports.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# [DEF:ReportsRouter:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: api, reports, list, detail, pagination, filters
|
||||
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
|
||||
from ...services.reports.report_service import ReportsService
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["Reports"])
|
||||
|
||||
|
||||
# [DEF:_parse_csv_enum_list:Function]
|
||||
# @PURPOSE: Parse comma-separated query value into enum list.
|
||||
# @PRE: raw may be None/empty or comma-separated values.
|
||||
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
|
||||
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
|
||||
# @PARAM: enum_cls (type) - Enum class for validation.
|
||||
# @PARAM: field_name (str) - Query field name for diagnostics.
|
||||
# @RETURN: List - Parsed enum values.
|
||||
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
if raw is None or not raw.strip():
|
||||
return []
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
parsed = []
|
||||
invalid = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(enum_cls(value))
|
||||
except ValueError:
|
||||
invalid.append(value)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Invalid values for '{field_name}'",
|
||||
"field": field_name,
|
||||
"invalid_values": invalid,
|
||||
"allowed_values": [item.value for item in enum_cls],
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
# [/DEF:_parse_csv_enum_list:Function]
|
||||
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return paginated unified reports list.
|
||||
# @PRE: authenticated/authorized request and validated query params.
|
||||
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @POST: deterministic error payload for invalid filters.
|
||||
@router.get("", response_model=ReportCollection)
|
||||
async def list_reports(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
task_types: Optional[str] = Query(None, description="Comma-separated task types"),
|
||||
statuses: Optional[str] = Query(None, description="Comma-separated statuses"),
|
||||
time_from: Optional[datetime] = Query(None),
|
||||
time_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None, max_length=200),
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("list_reports"):
|
||||
try:
|
||||
parsed_task_types = _parse_csv_enum_list(task_types, TaskType, "task_types")
|
||||
parsed_statuses = _parse_csv_enum_list(statuses, ReportStatus, "statuses")
|
||||
query = ReportQuery(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
task_types=parsed_task_types,
|
||||
statuses=parsed_statuses,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "Invalid query parameters",
|
||||
"code": "INVALID_REPORT_QUERY",
|
||||
"reason": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
service = ReportsService(task_manager)
|
||||
return service.list_reports(query)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
|
||||
# @PRE: authenticated/authorized request and existing report_id.
|
||||
# @POST: returns normalized detail envelope or 404 when report is not found.
|
||||
@router.get("/{report_id}", response_model=ReportDetailView)
|
||||
async def get_report_detail(
|
||||
report_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_report_detail", f"report_id={report_id}"):
|
||||
service = ReportsService(task_manager)
|
||||
detail = service.get_report_detail(report_id)
|
||||
if not detail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
|
||||
)
|
||||
return detail
|
||||
# [/DEF:get_report_detail:Function]
|
||||
|
||||
# [/DEF:ReportsRouter:Module]
|
||||
@@ -4,7 +4,7 @@
|
||||
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
@@ -13,9 +13,15 @@ from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...core.task_manager.models import LogFilter, LogStats
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
TASK_TYPE_PLUGIN_MAP = {
|
||||
"llm_validation": ["llm_dashboard_validation"],
|
||||
"backup": ["superset-backup"],
|
||||
"migration": ["superset-migration"],
|
||||
}
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
@@ -79,18 +85,36 @@ async def create_task(
|
||||
# @PRE: task_manager must be available.
|
||||
# @POST: Returns a list of tasks.
|
||||
# @RETURN: List[Task] - List of tasks.
|
||||
async def list_tasks(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[TaskStatus] = None,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
"""
|
||||
Retrieve a list of tasks with pagination and optional status filter.
|
||||
"""
|
||||
with belief_scope("list_tasks"):
|
||||
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
|
||||
async def list_tasks(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
|
||||
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
|
||||
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
|
||||
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
"""
|
||||
Retrieve a list of tasks with pagination and optional status filter.
|
||||
"""
|
||||
with belief_scope("list_tasks"):
|
||||
plugin_filters = list(plugin_id) if plugin_id else []
|
||||
if task_type:
|
||||
if task_type not in TASK_TYPE_PLUGIN_MAP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
|
||||
)
|
||||
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
|
||||
|
||||
return task_manager.get_tasks(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status_filter,
|
||||
plugin_ids=plugin_filters or None,
|
||||
completed_only=completed_only
|
||||
)
|
||||
# [/DEF:list_tasks:Function]
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
@@ -276,4 +300,4 @@ async def clear_tasks(
|
||||
task_manager.clear_tasks(status)
|
||||
return
|
||||
# [/DEF:clear_tasks:Function]
|
||||
# [/DEF:TasksRouter:Module]
|
||||
# [/DEF:TasksRouter:Module]
|
||||
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -123,6 +123,7 @@ app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(datasets.router)
|
||||
app.include_router(reports.router)
|
||||
|
||||
|
||||
# [DEF:api.include_routers:Action]
|
||||
@@ -241,6 +242,10 @@ frontend_path = project_root / "frontend" / "build"
|
||||
if frontend_path.exists():
|
||||
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
|
||||
|
||||
# [DEF:serve_spa:Function]
|
||||
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
|
||||
# @PRE: frontend_path exists.
|
||||
# @POST: Returns the requested file or index.html.
|
||||
@app.get("/{file_path:path}", include_in_schema=False)
|
||||
async def serve_spa(file_path: str):
|
||||
# Only serve SPA for non-API paths
|
||||
|
||||
@@ -24,7 +24,10 @@ class AuthConfig(BaseSettings):
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Database Settings
|
||||
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
|
||||
AUTH_DATABASE_URL: str = Field(
|
||||
default="postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
|
||||
env="AUTH_DATABASE_URL",
|
||||
)
|
||||
|
||||
# ADFS Settings
|
||||
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
|
||||
@@ -41,4 +44,4 @@ class AuthConfig(BaseSettings):
|
||||
auth_config = AuthConfig()
|
||||
# [/DEF:auth_config:Variable]
|
||||
|
||||
# [/DEF:backend.src.core.auth.config:Module]
|
||||
# [/DEF:backend.src.core.auth.config:Module]
|
||||
|
||||
@@ -8,14 +8,9 @@
|
||||
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from passlib.context import CryptContext
|
||||
import bcrypt
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:pwd_context:Variable]
|
||||
# @PURPOSE: Passlib CryptContext for password management.
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# [/DEF:pwd_context:Variable]
|
||||
|
||||
# [DEF:verify_password:Function]
|
||||
# @PURPOSE: Verifies a plain password against a hashed password.
|
||||
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
|
||||
@@ -25,7 +20,15 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# @PARAM: hashed_password (str) - The stored hash.
|
||||
# @RETURN: bool - Verification result.
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
if not hashed_password:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
# [/DEF:verify_password:Function]
|
||||
|
||||
# [DEF:get_password_hash:Function]
|
||||
@@ -36,7 +39,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
# @PARAM: password (str) - The password to hash.
|
||||
# @RETURN: str - The generated hash.
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
# [/DEF:get_password_hash:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
|
||||
570
backend/src/core/config_manager.py
Executable file → Normal file
570
backend/src/core/config_manager.py
Executable file → Normal file
@@ -1,284 +1,286 @@
|
||||
# [DEF:ConfigManagerModule:Module]
|
||||
#
|
||||
# @SEMANTICS: config, manager, persistence, json
|
||||
# @PURPOSE: Manages application configuration, including loading/saving to JSON and CRUD for environments.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> ConfigModels
|
||||
# @RELATION: CALLS -> logger
|
||||
# @RELATION: WRITES_TO -> config.json
|
||||
#
|
||||
# @INVARIANT: Configuration must always be valid according to AppConfig model.
|
||||
# @PUBLIC_API: ConfigManager
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
|
||||
from .logger import logger, configure_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:ConfigManager:Class]
|
||||
# @PURPOSE: A class to handle application configuration persistence and management.
|
||||
# @RELATION: WRITES_TO -> config.json
|
||||
class ConfigManager:
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the ConfigManager.
|
||||
# @PRE: isinstance(config_path, str) and len(config_path) > 0
|
||||
# @POST: self.config is an instance of AppConfig
|
||||
# @PARAM: config_path (str) - Path to the configuration file.
|
||||
def __init__(self, config_path: str = "config.json"):
|
||||
with belief_scope("__init__"):
|
||||
# 1. Runtime check of @PRE
|
||||
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
|
||||
|
||||
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
|
||||
|
||||
# 2. Logic implementation
|
||||
self.config_path = Path(config_path)
|
||||
self.config: AppConfig = self._load_config()
|
||||
|
||||
# Configure logger with loaded settings
|
||||
configure_logger(self.config.settings.logging)
|
||||
|
||||
# 3. Runtime check of @POST
|
||||
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
||||
|
||||
logger.info("[ConfigManager][Exit] Initialized")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_load_config:Function]
|
||||
# @PURPOSE: Loads the configuration from disk or creates a default one.
|
||||
# @PRE: self.config_path is set.
|
||||
# @POST: isinstance(return, AppConfig)
|
||||
# @RETURN: AppConfig - The loaded or default configuration.
|
||||
def _load_config(self) -> AppConfig:
|
||||
with belief_scope("_load_config"):
|
||||
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
|
||||
|
||||
if not self.config_path.exists():
|
||||
logger.info("[_load_config][Action] Config file not found. Creating default.")
|
||||
default_config = AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings()
|
||||
)
|
||||
self._save_config_to_disk(default_config)
|
||||
return default_config
|
||||
try:
|
||||
with open(self.config_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check for deprecated field
|
||||
if "settings" in data and "backup_path" in data["settings"]:
|
||||
del data["settings"]["backup_path"]
|
||||
|
||||
config = AppConfig(**data)
|
||||
logger.info("[_load_config][Coherence:OK] Configuration loaded")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
|
||||
# Fallback but try to preserve existing settings if possible?
|
||||
# For now, return default to be safe, but log the error prominently.
|
||||
return AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings(storage=StorageConfig())
|
||||
)
|
||||
# [/DEF:_load_config:Function]
|
||||
|
||||
# [DEF:_save_config_to_disk:Function]
|
||||
# @PURPOSE: Saves the provided configuration object to disk.
|
||||
# @PRE: isinstance(config, AppConfig)
|
||||
# @POST: Configuration saved to disk.
|
||||
# @PARAM: config (AppConfig) - The configuration to save.
|
||||
def _save_config_to_disk(self, config: AppConfig):
|
||||
with belief_scope("_save_config_to_disk"):
|
||||
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
|
||||
|
||||
# 2. Logic implementation
|
||||
try:
|
||||
with open(self.config_path, "w") as f:
|
||||
json.dump(config.dict(), f, indent=4)
|
||||
logger.info("[_save_config_to_disk][Action] Configuration saved")
|
||||
except Exception as e:
|
||||
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
|
||||
# [/DEF:_save_config_to_disk:Function]
|
||||
|
||||
# [DEF:save:Function]
|
||||
# @PURPOSE: Saves the current configuration state to disk.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: self._save_config_to_disk called.
|
||||
def save(self):
|
||||
with belief_scope("save"):
|
||||
self._save_config_to_disk(self.config)
|
||||
# [/DEF:save:Function]
|
||||
|
||||
# [DEF:get_config:Function]
|
||||
# @PURPOSE: Returns the current configuration.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns self.config.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
def get_config(self) -> AppConfig:
|
||||
with belief_scope("get_config"):
|
||||
return self.config
|
||||
# [/DEF:get_config:Function]
|
||||
|
||||
# [DEF:update_global_settings:Function]
|
||||
# @PURPOSE: Updates the global settings and persists the change.
|
||||
# @PRE: isinstance(settings, GlobalSettings)
|
||||
# @POST: self.config.settings updated and saved.
|
||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||
def update_global_settings(self, settings: GlobalSettings):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info("[update_global_settings][Entry] Updating settings")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
||||
|
||||
# 2. Logic implementation
|
||||
self.config.settings = settings
|
||||
self.save()
|
||||
|
||||
# Reconfigure logger with new settings
|
||||
configure_logger(settings.logging)
|
||||
|
||||
logger.info("[update_global_settings][Exit] Settings updated")
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
# @PURPOSE: Validates if a path exists and is writable.
|
||||
# @PRE: path is a string.
|
||||
# @POST: Returns (bool, str) status.
|
||||
# @PARAM: path (str) - The path to validate.
|
||||
# @RETURN: tuple (bool, str) - (is_valid, message)
|
||||
def validate_path(self, path: str) -> tuple[bool, str]:
|
||||
with belief_scope("validate_path"):
|
||||
p = os.path.abspath(path)
|
||||
if not os.path.exists(p):
|
||||
try:
|
||||
os.makedirs(p, exist_ok=True)
|
||||
except Exception as e:
|
||||
return False, f"Path does not exist and could not be created: {e}"
|
||||
|
||||
if not os.access(p, os.W_OK):
|
||||
return False, "Path is not writable"
|
||||
|
||||
return True, "Path is valid and writable"
|
||||
# [/DEF:validate_path:Function]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
# @PURPOSE: Returns the list of configured environments.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns list of environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
def get_environments(self) -> List[Environment]:
|
||||
with belief_scope("get_environments"):
|
||||
return self.config.environments
|
||||
# [/DEF:get_environments:Function]
|
||||
|
||||
# [DEF:has_environments:Function]
|
||||
# @PURPOSE: Checks if at least one environment is configured.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns boolean indicating if environments exist.
|
||||
# @RETURN: bool - True if at least one environment exists.
|
||||
def has_environments(self) -> bool:
|
||||
with belief_scope("has_environments"):
|
||||
return len(self.config.environments) > 0
|
||||
# [/DEF:has_environments:Function]
|
||||
|
||||
# [DEF:get_environment:Function]
|
||||
# @PURPOSE: Returns a single environment by ID.
|
||||
# @PRE: self.config is set and isinstance(env_id, str) and len(env_id) > 0.
|
||||
# @POST: Returns Environment object if found, None otherwise.
|
||||
# @PARAM: env_id (str) - The ID of the environment to retrieve.
|
||||
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
|
||||
def get_environment(self, env_id: str) -> Optional[Environment]:
|
||||
with belief_scope("get_environment"):
|
||||
for env in self.config.environments:
|
||||
if env.id == env_id:
|
||||
return env
|
||||
return None
|
||||
# [/DEF:get_environment:Function]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
# @PURPOSE: Adds a new environment to the configuration.
|
||||
# @PRE: isinstance(env, Environment)
|
||||
# @POST: Environment added or updated in self.config.environments.
|
||||
# @PARAM: env (Environment) - The environment to add.
|
||||
def add_environment(self, env: Environment):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert isinstance(env, Environment), "env must be an instance of Environment"
|
||||
|
||||
# 2. Logic implementation
|
||||
# Check for duplicate ID and remove if exists
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env.id]
|
||||
self.config.environments.append(env)
|
||||
self.save()
|
||||
|
||||
logger.info("[add_environment][Exit] Environment added")
|
||||
# [/DEF:add_environment:Function]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
# @PURPOSE: Updates an existing environment.
|
||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment)
|
||||
# @POST: Returns True if environment was found and updated.
|
||||
# @PARAM: env_id (str) - The ID of the environment to update.
|
||||
# @PARAM: updated_env (Environment) - The updated environment data.
|
||||
# @RETURN: bool - True if updated, False otherwise.
|
||||
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
|
||||
with belief_scope("update_environment"):
|
||||
logger.info(f"[update_environment][Entry] Updating {env_id}")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
|
||||
|
||||
# 2. Logic implementation
|
||||
for i, env in enumerate(self.config.environments):
|
||||
if env.id == env_id:
|
||||
# If password is masked, keep the old one
|
||||
if updated_env.password == "********":
|
||||
updated_env.password = env.password
|
||||
|
||||
self.config.environments[i] = updated_env
|
||||
self.save()
|
||||
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
|
||||
return True
|
||||
|
||||
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
return False
|
||||
# [/DEF:update_environment:Function]
|
||||
|
||||
# [DEF:delete_environment:Function]
|
||||
# @PURPOSE: Deletes an environment by ID.
|
||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0
|
||||
# @POST: Environment removed from self.config.environments if it existed.
|
||||
# @PARAM: env_id (str) - The ID of the environment to delete.
|
||||
def delete_environment(self, env_id: str):
|
||||
with belief_scope("delete_environment"):
|
||||
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
|
||||
# 2. Logic implementation
|
||||
original_count = len(self.config.environments)
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env_id]
|
||||
|
||||
if len(self.config.environments) < original_count:
|
||||
self.save()
|
||||
logger.info(f"[delete_environment][Action] Deleted {env_id}")
|
||||
else:
|
||||
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
# [/DEF:delete_environment:Function]
|
||||
|
||||
# [/DEF:ConfigManager:Class]
|
||||
|
||||
# [/DEF:ConfigManagerModule:Module]
|
||||
# [DEF:ConfigManagerModule:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: config, manager, persistence, postgresql
|
||||
# @PURPOSE: Manages application configuration persisted in database with one-time migration from JSON.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> ConfigModels
|
||||
# @RELATION: DEPENDS_ON -> AppConfigRecord
|
||||
# @RELATION: CALLS -> logger
|
||||
#
|
||||
# @INVARIANT: Configuration must always be valid according to AppConfig model.
|
||||
# @PUBLIC_API: ConfigManager
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
|
||||
from .database import SessionLocal
|
||||
from ..models.config import AppConfigRecord
|
||||
from .logger import logger, configure_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ConfigManager:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: A class to handle application configuration persistence and management.
|
||||
class ConfigManager:
|
||||
# [DEF:__init__:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Initializes the ConfigManager.
|
||||
# @PRE: isinstance(config_path, str) and len(config_path) > 0
|
||||
# @POST: self.config is an instance of AppConfig
|
||||
# @PARAM: config_path (str) - Path to legacy JSON config (used only for initial migration fallback).
|
||||
def __init__(self, config_path: str = "config.json"):
|
||||
with belief_scope("__init__"):
|
||||
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
|
||||
|
||||
logger.info(f"[ConfigManager][Entry] Initializing with legacy path {config_path}")
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.config: AppConfig = self._load_config()
|
||||
|
||||
configure_logger(self.config.settings.logging)
|
||||
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
||||
|
||||
logger.info("[ConfigManager][Exit] Initialized")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_default_config:Function]
|
||||
# @PURPOSE: Returns default application configuration.
|
||||
# @RETURN: AppConfig - Default configuration.
|
||||
def _default_config(self) -> AppConfig:
|
||||
return AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings(storage=StorageConfig()),
|
||||
)
|
||||
# [/DEF:_default_config:Function]
|
||||
|
||||
# [DEF:_load_from_legacy_file:Function]
|
||||
# @PURPOSE: Loads legacy configuration from config.json for migration fallback.
|
||||
# @RETURN: AppConfig - Loaded or default configuration.
|
||||
def _load_from_legacy_file(self) -> AppConfig:
|
||||
with belief_scope("_load_from_legacy_file"):
|
||||
if not self.config_path.exists():
|
||||
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
|
||||
return self._default_config()
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
|
||||
return AppConfig(**data)
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
|
||||
return self._default_config()
|
||||
# [/DEF:_load_from_legacy_file:Function]
|
||||
|
||||
# [DEF:_get_record:Function]
|
||||
# @PURPOSE: Loads config record from DB.
|
||||
# @PARAM: session (Session) - DB session.
|
||||
# @RETURN: Optional[AppConfigRecord] - Existing record or None.
|
||||
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
|
||||
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
||||
# [/DEF:_get_record:Function]
|
||||
|
||||
# [DEF:_load_config:Function]
|
||||
# @PURPOSE: Loads the configuration from DB or performs one-time migration from JSON file.
|
||||
# @PRE: DB session factory is available.
|
||||
# @POST: isinstance(return, AppConfig)
|
||||
# @RETURN: AppConfig - Loaded configuration.
|
||||
def _load_config(self) -> AppConfig:
|
||||
with belief_scope("_load_config"):
|
||||
session: Session = SessionLocal()
|
||||
try:
|
||||
record = self._get_record(session)
|
||||
if record and record.payload:
|
||||
logger.info("[_load_config][Coherence:OK] Configuration loaded from database")
|
||||
return AppConfig(**record.payload)
|
||||
|
||||
logger.info("[_load_config][Action] No database config found, migrating legacy config")
|
||||
config = self._load_from_legacy_file()
|
||||
self._save_config_to_db(config, session=session)
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_config][Coherence:Failed] Error loading config from DB: {e}")
|
||||
return self._default_config()
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:_load_config:Function]
|
||||
|
||||
# [DEF:_save_config_to_db:Function]
|
||||
# @PURPOSE: Saves the provided configuration object to DB.
|
||||
# @PRE: isinstance(config, AppConfig)
|
||||
# @POST: Configuration saved to database.
|
||||
# @PARAM: config (AppConfig) - The configuration to save.
|
||||
# @PARAM: session (Optional[Session]) - Existing DB session for transactional reuse.
|
||||
def _save_config_to_db(self, config: AppConfig, session: Optional[Session] = None):
|
||||
with belief_scope("_save_config_to_db"):
|
||||
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
|
||||
|
||||
owns_session = session is None
|
||||
db = session or SessionLocal()
|
||||
try:
|
||||
record = self._get_record(db)
|
||||
payload = config.model_dump()
|
||||
if record is None:
|
||||
record = AppConfigRecord(id="global", payload=payload)
|
||||
db.add(record)
|
||||
else:
|
||||
record.payload = payload
|
||||
db.commit()
|
||||
logger.info("[_save_config_to_db][Action] Configuration saved to database")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[_save_config_to_db][Coherence:Failed] Failed to save: {e}")
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
# [/DEF:_save_config_to_db:Function]
|
||||
|
||||
# [DEF:save:Function]
|
||||
# @PURPOSE: Saves the current configuration state to DB.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: self._save_config_to_db called.
|
||||
def save(self):
|
||||
with belief_scope("save"):
|
||||
self._save_config_to_db(self.config)
|
||||
# [/DEF:save:Function]
|
||||
|
||||
# [DEF:get_config:Function]
|
||||
# @PURPOSE: Returns the current configuration.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
def get_config(self) -> AppConfig:
|
||||
with belief_scope("get_config"):
|
||||
return self.config
|
||||
# [/DEF:get_config:Function]
|
||||
|
||||
# [DEF:update_global_settings:Function]
|
||||
# @PURPOSE: Updates the global settings and persists the change.
|
||||
# @PRE: isinstance(settings, GlobalSettings)
|
||||
# @POST: self.config.settings updated and saved.
|
||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||
def update_global_settings(self, settings: GlobalSettings):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info("[update_global_settings][Entry] Updating settings")
|
||||
|
||||
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
||||
self.config.settings = settings
|
||||
self.save()
|
||||
configure_logger(settings.logging)
|
||||
logger.info("[update_global_settings][Exit] Settings updated")
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
# @PURPOSE: Validates if a path exists and is writable.
|
||||
# @PARAM: path (str) - The path to validate.
|
||||
# @RETURN: tuple (bool, str) - (is_valid, message)
|
||||
def validate_path(self, path: str) -> tuple[bool, str]:
|
||||
with belief_scope("validate_path"):
|
||||
p = os.path.abspath(path)
|
||||
if not os.path.exists(p):
|
||||
try:
|
||||
os.makedirs(p, exist_ok=True)
|
||||
except Exception as e:
|
||||
return False, f"Path does not exist and could not be created: {e}"
|
||||
|
||||
if not os.access(p, os.W_OK):
|
||||
return False, "Path is not writable"
|
||||
|
||||
return True, "Path is valid and writable"
|
||||
# [/DEF:validate_path:Function]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
# @PURPOSE: Returns the list of configured environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
def get_environments(self) -> List[Environment]:
|
||||
with belief_scope("get_environments"):
|
||||
return self.config.environments
|
||||
# [/DEF:get_environments:Function]
|
||||
|
||||
# [DEF:has_environments:Function]
|
||||
# @PURPOSE: Checks if at least one environment is configured.
|
||||
# @RETURN: bool - True if at least one environment exists.
|
||||
def has_environments(self) -> bool:
|
||||
with belief_scope("has_environments"):
|
||||
return len(self.config.environments) > 0
|
||||
# [/DEF:has_environments:Function]
|
||||
|
||||
# [DEF:get_environment:Function]
|
||||
# @PURPOSE: Returns a single environment by ID.
|
||||
# @PARAM: env_id (str) - The ID of the environment to retrieve.
|
||||
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
|
||||
def get_environment(self, env_id: str) -> Optional[Environment]:
|
||||
with belief_scope("get_environment"):
|
||||
for env in self.config.environments:
|
||||
if env.id == env_id:
|
||||
return env
|
||||
return None
|
||||
# [/DEF:get_environment:Function]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
# @PURPOSE: Adds a new environment to the configuration.
|
||||
# @PARAM: env (Environment) - The environment to add.
|
||||
def add_environment(self, env: Environment):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
assert isinstance(env, Environment), "env must be an instance of Environment"
|
||||
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env.id]
|
||||
self.config.environments.append(env)
|
||||
self.save()
|
||||
logger.info("[add_environment][Exit] Environment added")
|
||||
# [/DEF:add_environment:Function]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
# @PURPOSE: Updates an existing environment.
|
||||
# @PARAM: env_id (str) - The ID of the environment to update.
|
||||
# @PARAM: updated_env (Environment) - The updated environment data.
|
||||
# @RETURN: bool - True if updated, False otherwise.
|
||||
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
|
||||
with belief_scope("update_environment"):
|
||||
logger.info(f"[update_environment][Entry] Updating {env_id}")
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
|
||||
|
||||
for i, env in enumerate(self.config.environments):
|
||||
if env.id == env_id:
|
||||
if updated_env.password == "********":
|
||||
updated_env.password = env.password
|
||||
|
||||
self.config.environments[i] = updated_env
|
||||
self.save()
|
||||
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
|
||||
return True
|
||||
|
||||
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
return False
|
||||
# [/DEF:update_environment:Function]
|
||||
|
||||
# [DEF:delete_environment:Function]
|
||||
# @PURPOSE: Deletes an environment by ID.
|
||||
# @PARAM: env_id (str) - The ID of the environment to delete.
|
||||
def delete_environment(self, env_id: str):
|
||||
with belief_scope("delete_environment"):
|
||||
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
|
||||
original_count = len(self.config.environments)
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env_id]
|
||||
|
||||
if len(self.config.environments) < original_count:
|
||||
self.save()
|
||||
logger.info(f"[delete_environment][Action] Deleted {env_id}")
|
||||
else:
|
||||
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
# [/DEF:delete_environment:Function]
|
||||
|
||||
|
||||
# [/DEF:ConfigManager:Class]
|
||||
# [/DEF:ConfigManagerModule:Module]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# @SEMANTICS: config, models, pydantic
|
||||
# @PURPOSE: Defines the data models for application configuration using Pydantic.
|
||||
# @LAYER: Core
|
||||
# @RELATION: READS_FROM -> config.json
|
||||
# @RELATION: READS_FROM -> app_configurations (database)
|
||||
# @RELATION: USED_BY -> ConfigManager
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -33,10 +33,10 @@ class Environment(BaseModel):
|
||||
|
||||
# [DEF:LoggingConfig:DataClass]
|
||||
# @PURPOSE: Defines the configuration for the application's logging system.
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = "INFO"
|
||||
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
|
||||
file_path: Optional[str] = "logs/app.log"
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = "INFO"
|
||||
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
|
||||
file_path: Optional[str] = None
|
||||
max_bytes: int = 10 * 1024 * 1024
|
||||
backup_count: int = 5
|
||||
enable_belief_state: bool = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# [DEF:backend.src.core.database:Module]
|
||||
#
|
||||
# @SEMANTICS: database, sqlite, sqlalchemy, session, persistence
|
||||
# @PURPOSE: Configures the SQLite database connection and session management.
|
||||
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
|
||||
# @PURPOSE: Configures database connection and session management (PostgreSQL-first).
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.mapping
|
||||
@@ -14,6 +14,10 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from ..models.mapping import Base
|
||||
# Import models to ensure they're registered with Base
|
||||
from ..models import task as _task_models # noqa: F401
|
||||
from ..models import auth as _auth_models # noqa: F401
|
||||
from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
@@ -21,44 +25,50 @@ from pathlib import Path
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:BASE_DIR:Variable]
|
||||
# @PURPOSE: Base directory for the backend (where .db files should reside).
|
||||
# @PURPOSE: Base directory for the backend.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
# [/DEF:BASE_DIR:Variable]
|
||||
|
||||
# [DEF:DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the main mappings database.
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/mappings.db")
|
||||
# @PURPOSE: URL for the main application database.
|
||||
DEFAULT_POSTGRES_URL = os.getenv(
|
||||
"POSTGRES_URL",
|
||||
"postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
|
||||
)
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_POSTGRES_URL)
|
||||
# [/DEF:DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the tasks execution database.
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", f"sqlite:///{BASE_DIR}/tasks.db")
|
||||
# Defaults to DATABASE_URL to keep task logs in the same PostgreSQL instance.
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", DATABASE_URL)
|
||||
# [/DEF:TASKS_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:AUTH_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the authentication database.
|
||||
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL)
|
||||
# If it's a relative sqlite path starting with ./backend/, fix it to be absolute or relative to BASE_DIR
|
||||
if AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./backend/", f"sqlite:///{BASE_DIR}/")
|
||||
elif AUTH_DATABASE_URL.startswith("sqlite:///./") and not AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||
# If it's just ./ but we are in backend, it's fine, but let's make it absolute for robustness
|
||||
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./", f"sqlite:///{BASE_DIR}/")
|
||||
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:engine:Variable]
|
||||
def _build_engine(db_url: str):
|
||||
with belief_scope("_build_engine"):
|
||||
if db_url.startswith("sqlite"):
|
||||
return create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
return create_engine(db_url, pool_pre_ping=True)
|
||||
|
||||
|
||||
# @PURPOSE: SQLAlchemy engine for mappings database.
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
engine = _build_engine(DATABASE_URL)
|
||||
# [/DEF:engine:Variable]
|
||||
|
||||
# [DEF:tasks_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for tasks database.
|
||||
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
tasks_engine = _build_engine(TASKS_DATABASE_URL)
|
||||
# [/DEF:tasks_engine:Variable]
|
||||
|
||||
# [DEF:auth_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for authentication database.
|
||||
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
auth_engine = _build_engine(AUTH_DATABASE_URL)
|
||||
# [/DEF:auth_engine:Variable]
|
||||
|
||||
# [DEF:SessionLocal:Class]
|
||||
|
||||
@@ -11,7 +11,7 @@ import asyncio
|
||||
import threading
|
||||
import inspect
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
|
||||
@@ -312,13 +312,35 @@ class TaskManager:
|
||||
# @PARAM: offset (int) - Number of tasks to skip.
|
||||
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||
# @RETURN: List[Task] - List of tasks matching criteria.
|
||||
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
|
||||
def get_tasks(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[TaskStatus] = None,
|
||||
plugin_ids: Optional[List[str]] = None,
|
||||
completed_only: bool = False
|
||||
) -> List[Task]:
|
||||
with belief_scope("TaskManager.get_tasks"):
|
||||
tasks = list(self.tasks.values())
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
# Sort by start_time descending (most recent first)
|
||||
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
|
||||
if plugin_ids:
|
||||
plugin_id_set = set(plugin_ids)
|
||||
tasks = [t for t in tasks if t.plugin_id in plugin_id_set]
|
||||
if completed_only:
|
||||
tasks = [t for t in tasks if t.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]]
|
||||
# Sort by started_at descending with tolerant handling of mixed tz-aware/naive values.
|
||||
def sort_key(task: Task) -> float:
|
||||
started_at = task.started_at
|
||||
if started_at is None:
|
||||
return float("-inf")
|
||||
if not isinstance(started_at, datetime):
|
||||
return float("-inf")
|
||||
if started_at.tzinfo is None:
|
||||
return started_at.replace(tzinfo=timezone.utc).timestamp()
|
||||
return started_at.timestamp()
|
||||
|
||||
tasks.sort(key=sort_key, reverse=True)
|
||||
return tasks[offset:offset + limit]
|
||||
# [/DEF:get_tasks:Function]
|
||||
|
||||
@@ -568,4 +590,4 @@ class TaskManager:
|
||||
# [/DEF:clear_tasks:Function]
|
||||
|
||||
# [/DEF:TaskManager:Class]
|
||||
# [/DEF:TaskManagerModule:Module]
|
||||
# [/DEF:TaskManagerModule:Module]
|
||||
|
||||
@@ -109,7 +109,8 @@ class Task(BaseModel):
|
||||
params: Dict[str, Any] = Field(default_factory=dict)
|
||||
input_required: bool = False
|
||||
input_request: Optional[Dict[str, Any]] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
# Result payload can be dict/list/scalar depending on plugin and legacy records.
|
||||
result: Optional[Any] = None
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
|
||||
@@ -123,4 +124,4 @@ class Task(BaseModel):
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:Task:Class]
|
||||
|
||||
# [/DEF:TaskManagerModels:Module]
|
||||
# [/DEF:TaskManagerModels:Module]
|
||||
|
||||
@@ -12,6 +12,7 @@ import json
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.task import TaskRecord, TaskLogRecord
|
||||
from ...models.mapping import Environment
|
||||
from ..database import TasksSessionLocal
|
||||
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
|
||||
from ..logger import logger, belief_scope
|
||||
@@ -21,6 +22,40 @@ from ..logger import logger, belief_scope
|
||||
# @SEMANTICS: persistence, service, database, sqlalchemy
|
||||
# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
|
||||
class TaskPersistenceService:
|
||||
@staticmethod
|
||||
def _json_load_if_needed(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped == "" or stripped.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value):
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> Optional[str]:
|
||||
if not env_id:
|
||||
return None
|
||||
exists = session.query(Environment.id).filter(Environment.id == env_id).first()
|
||||
return env_id if exists else None
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the persistence service.
|
||||
# @PRE: None.
|
||||
@@ -48,7 +83,8 @@ class TaskPersistenceService:
|
||||
|
||||
record.type = task.plugin_id
|
||||
record.status = task.status.value
|
||||
record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id")
|
||||
raw_env_id = task.params.get("environment_id") or task.params.get("source_env_id")
|
||||
record.environment_id = self._resolve_environment_id(session, raw_env_id)
|
||||
record.started_at = task.started_at
|
||||
record.finished_at = task.finished_at
|
||||
|
||||
@@ -123,21 +159,28 @@ class TaskPersistenceService:
|
||||
for record in records:
|
||||
try:
|
||||
logs = []
|
||||
if record.logs:
|
||||
for log_data in record.logs:
|
||||
# Handle timestamp conversion if it's a string
|
||||
if isinstance(log_data.get('timestamp'), str):
|
||||
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp'])
|
||||
logs_payload = self._json_load_if_needed(record.logs)
|
||||
if isinstance(logs_payload, list):
|
||||
for log_data in logs_payload:
|
||||
if not isinstance(log_data, dict):
|
||||
continue
|
||||
log_data = dict(log_data)
|
||||
log_data['timestamp'] = self._parse_datetime(log_data.get('timestamp')) or datetime.utcnow()
|
||||
logs.append(LogEntry(**log_data))
|
||||
|
||||
started_at = self._parse_datetime(record.started_at)
|
||||
finished_at = self._parse_datetime(record.finished_at)
|
||||
params = self._json_load_if_needed(record.params)
|
||||
result = self._json_load_if_needed(record.result)
|
||||
|
||||
task = Task(
|
||||
id=record.id,
|
||||
plugin_id=record.type,
|
||||
status=TaskStatus(record.status),
|
||||
started_at=record.started_at,
|
||||
finished_at=record.finished_at,
|
||||
params=record.params or {},
|
||||
result=record.result,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
params=params if isinstance(params, dict) else {},
|
||||
result=result,
|
||||
logs=logs
|
||||
)
|
||||
loaded_tasks.append(task)
|
||||
@@ -381,4 +424,4 @@ class TaskLogPersistenceService:
|
||||
# [/DEF:delete_logs_for_tasks:Function]
|
||||
|
||||
# [/DEF:TaskLogPersistenceService:Class]
|
||||
# [/DEF:TaskPersistenceModule:Module]
|
||||
# [/DEF:TaskPersistenceModule:Module]
|
||||
|
||||
@@ -20,14 +20,14 @@ from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .models.auth import User
|
||||
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
config_path = project_root / "config.json"
|
||||
config_manager = ConfigManager(config_path=str(config_path))
|
||||
|
||||
# Initialize database before any other services that might use it
|
||||
init_db()
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
config_path = project_root / "config.json"
|
||||
|
||||
# Initialize database before services that use persisted configuration.
|
||||
init_db()
|
||||
config_manager = ConfigManager(config_path=str(config_path))
|
||||
|
||||
# [DEF:get_config_manager:Function]
|
||||
# @PURPOSE: Dependency injector for ConfigManager.
|
||||
|
||||
26
backend/src/models/config.py
Normal file
26
backend/src/models/config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# [DEF:backend.src.models.config:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, config, settings, sqlalchemy
|
||||
# @PURPOSE: Defines database schema for persisted application configuration.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .mapping import Base
|
||||
|
||||
|
||||
# [DEF:AppConfigRecord:Class]
|
||||
# @PURPOSE: Stores the single source of truth for application configuration.
|
||||
class AppConfigRecord(Base):
|
||||
__tablename__ = "app_configurations"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
payload = Column(JSON, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# [/DEF:AppConfigRecord:Class]
|
||||
# [/DEF:backend.src.models.config:Module]
|
||||
128
backend/src/models/report.py
Normal file
128
backend/src/models/report.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# [DEF:backend.src.models.report:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, models, pydantic, normalization, pagination
|
||||
# @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models
|
||||
# @INVARIANT: Canonical report fields are always present for every report item.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:TaskType:Class]
|
||||
# @PURPOSE: Supported normalized task report types.
|
||||
class TaskType(str, Enum):
|
||||
LLM_VERIFICATION = "llm_verification"
|
||||
BACKUP = "backup"
|
||||
MIGRATION = "migration"
|
||||
DOCUMENTATION = "documentation"
|
||||
UNKNOWN = "unknown"
|
||||
# [/DEF:TaskType:Class]
|
||||
|
||||
|
||||
# [DEF:ReportStatus:Class]
|
||||
# @PURPOSE: Supported normalized report status values.
|
||||
class ReportStatus(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
IN_PROGRESS = "in_progress"
|
||||
PARTIAL = "partial"
|
||||
# [/DEF:ReportStatus:Class]
|
||||
|
||||
|
||||
# [DEF:ErrorContext:Class]
|
||||
# @PURPOSE: Error and recovery context for failed/partial reports.
|
||||
class ErrorContext(BaseModel):
|
||||
code: Optional[str] = None
|
||||
message: str
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ErrorContext:Class]
|
||||
|
||||
|
||||
# [DEF:TaskReport:Class]
|
||||
# @PURPOSE: Canonical normalized report envelope for one task execution.
|
||||
class TaskReport(BaseModel):
|
||||
report_id: str
|
||||
task_id: str
|
||||
task_type: TaskType
|
||||
status: ReportStatus
|
||||
started_at: Optional[datetime] = None
|
||||
updated_at: datetime
|
||||
summary: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
error_context: Optional[ErrorContext] = None
|
||||
source_ref: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("report_id", "task_id", "summary")
|
||||
@classmethod
|
||||
def _non_empty_str(cls, value: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("Value must be a non-empty string")
|
||||
return value.strip()
|
||||
# [/DEF:TaskReport:Class]
|
||||
|
||||
|
||||
# [DEF:ReportQuery:Class]
|
||||
# @PURPOSE: Query object for server-side report filtering, sorting, and pagination.
|
||||
class ReportQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
task_types: List[TaskType] = Field(default_factory=list)
|
||||
statuses: List[ReportStatus] = Field(default_factory=list)
|
||||
time_from: Optional[datetime] = None
|
||||
time_to: Optional[datetime] = None
|
||||
search: Optional[str] = Field(default=None, max_length=200)
|
||||
sort_by: str = Field(default="updated_at")
|
||||
sort_order: str = Field(default="desc")
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def _validate_sort_by(cls, value: str) -> str:
|
||||
allowed = {"updated_at", "status", "task_type"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}")
|
||||
return value
|
||||
|
||||
@field_validator("sort_order")
|
||||
@classmethod
|
||||
def _validate_sort_order(cls, value: str) -> str:
|
||||
if value not in {"asc", "desc"}:
|
||||
raise ValueError("sort_order must be 'asc' or 'desc'")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_range(self):
|
||||
if self.time_from and self.time_to and self.time_from > self.time_to:
|
||||
raise ValueError("time_from must be less than or equal to time_to")
|
||||
return self
|
||||
# [/DEF:ReportQuery:Class]
|
||||
|
||||
|
||||
# [DEF:ReportCollection:Class]
|
||||
# @PURPOSE: Paginated collection of normalized task reports.
|
||||
class ReportCollection(BaseModel):
|
||||
items: List[TaskReport]
|
||||
total: int = Field(ge=0)
|
||||
page: int = Field(ge=1)
|
||||
page_size: int = Field(ge=1)
|
||||
has_next: bool
|
||||
applied_filters: ReportQuery
|
||||
# [/DEF:ReportCollection:Class]
|
||||
|
||||
|
||||
# [DEF:ReportDetailView:Class]
|
||||
# @PURPOSE: Detailed report representation including diagnostics and recovery actions.
|
||||
class ReportDetailView(BaseModel):
|
||||
report: TaskReport
|
||||
timeline: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: Optional[Dict[str, Any]] = None
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ReportDetailView:Class]
|
||||
|
||||
# [/DEF:backend.src.models.report:Module]
|
||||
@@ -22,6 +22,8 @@ class FileCategory(str, Enum):
|
||||
# @PURPOSE: Configuration model for the storage system, defining paths and naming patterns.
|
||||
class StorageConfig(BaseModel):
|
||||
root_path: str = Field(default="backups", description="Absolute path to the storage root directory.")
|
||||
backup_path: str = Field(default="backups", description="Subpath for backups.")
|
||||
repo_path: str = Field(default="repositorys", description="Subpath for repositories.")
|
||||
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
|
||||
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
|
||||
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")
|
||||
|
||||
@@ -154,10 +154,10 @@ class BackupPlugin(PluginBase):
|
||||
|
||||
log.info(f"Starting backup for environment: {env}")
|
||||
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
if not config_manager.has_environments():
|
||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
if not config_manager.has_environments():
|
||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||
|
||||
env_config = config_manager.get_environment(env)
|
||||
if not env_config:
|
||||
@@ -180,16 +180,27 @@ class BackupPlugin(PluginBase):
|
||||
superset_log.info("No dashboard filter applied - backing up all dashboards")
|
||||
dashboard_meta = all_dashboard_meta
|
||||
|
||||
if dashboard_count == 0:
|
||||
log.info("No dashboards to back up")
|
||||
return
|
||||
|
||||
total = len(dashboard_meta)
|
||||
for idx, db in enumerate(dashboard_meta, 1):
|
||||
dashboard_id = db.get('id')
|
||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||
if not dashboard_id:
|
||||
continue
|
||||
if dashboard_count == 0:
|
||||
log.info("No dashboards to back up")
|
||||
return {
|
||||
"status": "NO_DASHBOARDS",
|
||||
"environment": env,
|
||||
"backup_root": str(backup_path / env.upper()),
|
||||
"total_dashboards": 0,
|
||||
"backed_up_dashboards": 0,
|
||||
"failed_dashboards": 0,
|
||||
"dashboards": [],
|
||||
"failures": []
|
||||
}
|
||||
|
||||
total = len(dashboard_meta)
|
||||
backed_up_dashboards = []
|
||||
failed_dashboards = []
|
||||
for idx, db in enumerate(dashboard_meta, 1):
|
||||
dashboard_id = db.get('id')
|
||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||
if not dashboard_id:
|
||||
continue
|
||||
|
||||
# Report progress
|
||||
progress_pct = (idx / total) * 100
|
||||
@@ -210,21 +221,41 @@ class BackupPlugin(PluginBase):
|
||||
unpack=False
|
||||
)
|
||||
|
||||
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
|
||||
storage_log.debug(f"Archived dashboard: {dashboard_title}")
|
||||
|
||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||
continue
|
||||
|
||||
consolidate_archive_folders(backup_path / env.upper())
|
||||
remove_empty_directories(str(backup_path / env.upper()))
|
||||
|
||||
log.info(f"Backup completed successfully for {env}")
|
||||
|
||||
except (RequestException, IOError, KeyError) as e:
|
||||
log.error(f"Fatal error during backup for {env}: {e}")
|
||||
raise e
|
||||
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
|
||||
storage_log.debug(f"Archived dashboard: {dashboard_title}")
|
||||
backed_up_dashboards.append({
|
||||
"id": dashboard_id,
|
||||
"title": dashboard_title,
|
||||
"path": str(dashboard_dir)
|
||||
})
|
||||
|
||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||
failed_dashboards.append({
|
||||
"id": dashboard_id,
|
||||
"title": dashboard_title,
|
||||
"error": str(db_error)
|
||||
})
|
||||
continue
|
||||
|
||||
consolidate_archive_folders(backup_path / env.upper())
|
||||
remove_empty_directories(str(backup_path / env.upper()))
|
||||
|
||||
log.info(f"Backup completed successfully for {env}")
|
||||
return {
|
||||
"status": "SUCCESS" if not failed_dashboards else "PARTIAL_SUCCESS",
|
||||
"environment": env,
|
||||
"backup_root": str(backup_path / env.upper()),
|
||||
"total_dashboards": total,
|
||||
"backed_up_dashboards": len(backed_up_dashboards),
|
||||
"failed_dashboards": len(failed_dashboards),
|
||||
"dashboards": backed_up_dashboards,
|
||||
"failures": failed_dashboards
|
||||
}
|
||||
|
||||
except (RequestException, IOError, KeyError) as e:
|
||||
log.error(f"Fatal error during backup for {env}: {e}")
|
||||
raise e
|
||||
# [/DEF:execute:Function]
|
||||
# [/DEF:BackupPlugin:Class]
|
||||
# [/DEF:BackupPlugin:Module]
|
||||
# [/DEF:BackupPlugin:Module]
|
||||
|
||||
@@ -74,7 +74,8 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
log.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
dashboard_id = params.get("dashboard_id")
|
||||
dashboard_id_raw = params.get("dashboard_id")
|
||||
dashboard_id = str(dashboard_id_raw) if dashboard_id_raw is not None else None
|
||||
env_id = params.get("environment_id")
|
||||
provider_id = params.get("provider_id")
|
||||
|
||||
|
||||
@@ -165,11 +165,11 @@ class MigrationPlugin(PluginBase):
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
migration_log = log.with_source("migration") if context else log
|
||||
|
||||
log.info("Starting migration task.")
|
||||
log.debug(f"Params: {params}")
|
||||
|
||||
try:
|
||||
with belief_scope("execute"):
|
||||
log.info("Starting migration task.")
|
||||
log.debug(f"Params: {params}")
|
||||
|
||||
try:
|
||||
with belief_scope("execute"):
|
||||
config_manager = get_config_manager()
|
||||
environments = config_manager.get_environments()
|
||||
|
||||
@@ -192,11 +192,20 @@ class MigrationPlugin(PluginBase):
|
||||
|
||||
from_env_name = src_env.name
|
||||
to_env_name = tgt_env.name
|
||||
|
||||
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
|
||||
|
||||
from_c = SupersetClient(src_env)
|
||||
to_c = SupersetClient(tgt_env)
|
||||
|
||||
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
|
||||
migration_result = {
|
||||
"status": "SUCCESS",
|
||||
"source_environment": from_env_name,
|
||||
"target_environment": to_env_name,
|
||||
"selected_dashboards": 0,
|
||||
"migrated_dashboards": [],
|
||||
"failed_dashboards": [],
|
||||
"mapping_count": 0
|
||||
}
|
||||
|
||||
from_c = SupersetClient(src_env)
|
||||
to_c = SupersetClient(tgt_env)
|
||||
|
||||
if not from_c or not to_c:
|
||||
raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}")
|
||||
@@ -204,20 +213,24 @@ class MigrationPlugin(PluginBase):
|
||||
_, all_dashboards = from_c.get_dashboards()
|
||||
|
||||
dashboards_to_migrate = []
|
||||
if selected_ids:
|
||||
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
|
||||
elif dashboard_regex:
|
||||
regex_str = str(dashboard_regex)
|
||||
dashboards_to_migrate = [
|
||||
if selected_ids:
|
||||
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
|
||||
elif dashboard_regex:
|
||||
regex_str = str(dashboard_regex)
|
||||
dashboards_to_migrate = [
|
||||
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
|
||||
]
|
||||
else:
|
||||
log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
|
||||
return
|
||||
|
||||
if not dashboards_to_migrate:
|
||||
log.warning("No dashboards found matching criteria.")
|
||||
return
|
||||
else:
|
||||
log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
|
||||
migration_result["status"] = "NO_SELECTION"
|
||||
return migration_result
|
||||
|
||||
if not dashboards_to_migrate:
|
||||
log.warning("No dashboards found matching criteria.")
|
||||
migration_result["status"] = "NO_MATCHES"
|
||||
return migration_result
|
||||
|
||||
migration_result["selected_dashboards"] = len(dashboards_to_migrate)
|
||||
|
||||
# Get mappings from params
|
||||
db_mapping = params.get("db_mappings", {})
|
||||
@@ -238,17 +251,18 @@ class MigrationPlugin(PluginBase):
|
||||
DatabaseMapping.target_env_id == tgt_env_db.id
|
||||
).all()
|
||||
# Provided mappings override stored ones
|
||||
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
|
||||
stored_map_dict.update(db_mapping)
|
||||
db_mapping = stored_map_dict
|
||||
log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
engine = MigrationEngine()
|
||||
|
||||
for dash in dashboards_to_migrate:
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
|
||||
stored_map_dict.update(db_mapping)
|
||||
db_mapping = stored_map_dict
|
||||
log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
migration_result["mapping_count"] = len(db_mapping)
|
||||
engine = MigrationEngine()
|
||||
|
||||
for dash in dashboards_to_migrate:
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
|
||||
try:
|
||||
exported_content, _ = from_c.export_dashboard(dash_id)
|
||||
@@ -279,13 +293,22 @@ class MigrationPlugin(PluginBase):
|
||||
db.close()
|
||||
success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
|
||||
|
||||
if success:
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
else:
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
|
||||
superset_log.info(f"Dashboard {title} imported.")
|
||||
except Exception as exc:
|
||||
if success:
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
migration_result["migrated_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title
|
||||
})
|
||||
else:
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title,
|
||||
"error": "Failed to transform ZIP"
|
||||
})
|
||||
|
||||
superset_log.info(f"Dashboard {title} imported.")
|
||||
except Exception as exc:
|
||||
# Check for password error
|
||||
error_msg = str(exc)
|
||||
# The error message from Superset is often a JSON string inside a string.
|
||||
@@ -324,22 +347,34 @@ class MigrationPlugin(PluginBase):
|
||||
passwords = task.params.get("passwords", {})
|
||||
|
||||
# Retry import with password
|
||||
if passwords:
|
||||
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
# Clear passwords from params after use for security
|
||||
if "passwords" in task.params:
|
||||
del task.params["passwords"]
|
||||
continue
|
||||
|
||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
|
||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
except Exception as e:
|
||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
raise e
|
||||
if passwords:
|
||||
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
migration_result["migrated_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title
|
||||
})
|
||||
# Clear passwords from params after use for security
|
||||
if "passwords" in task.params:
|
||||
del task.params["passwords"]
|
||||
continue
|
||||
|
||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id,
|
||||
"title": title,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
if migration_result["failed_dashboards"]:
|
||||
migration_result["status"] = "PARTIAL_SUCCESS"
|
||||
return migration_result
|
||||
except Exception as e:
|
||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
raise e
|
||||
# [/DEF:MigrationPlugin.execute:Action]
|
||||
# [/DEF:execute:Function]
|
||||
# [/DEF:MigrationPlugin:Class]
|
||||
# [/DEF:MigrationPlugin:Module]
|
||||
# [/DEF:MigrationPlugin:Module]
|
||||
|
||||
361
backend/src/scripts/migrate_sqlite_to_postgres.py
Normal file
361
backend/src/scripts/migrate_sqlite_to_postgres.py
Normal file
@@ -0,0 +1,361 @@
|
||||
# [DEF:backend.src.scripts.migrate_sqlite_to_postgres:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, sqlite, postgresql, config, task_logs, task_records
|
||||
# @PURPOSE: Migrates legacy config and task history from SQLite/file storage to PostgreSQL.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: READS_FROM -> backend/tasks.db
|
||||
# @RELATION: READS_FROM -> backend/config.json
|
||||
# @RELATION: WRITES_TO -> postgresql.task_records
|
||||
# @RELATION: WRITES_TO -> postgresql.task_logs
|
||||
# @RELATION: WRITES_TO -> postgresql.app_configurations
|
||||
#
|
||||
# @INVARIANT: Script is idempotent for task_records and app_configurations.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from src.core.logger import belief_scope, logger
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:Constants:Section]
|
||||
DEFAULT_TARGET_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
os.getenv("POSTGRES_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools"),
|
||||
)
|
||||
# [/DEF:Constants:Section]
|
||||
|
||||
|
||||
# [DEF:_json_load_if_needed:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Parses JSON-like values from SQLite TEXT/JSON columns to Python objects.
|
||||
# @PRE: value is scalar JSON/text/list/dict or None.
|
||||
# @POST: Returns normalized Python object or original scalar value.
|
||||
def _json_load_if_needed(value: Any) -> Any:
|
||||
with belief_scope("_json_load_if_needed"):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw[0] in "{[":
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
# [/DEF:_json_load_if_needed:Function]
|
||||
|
||||
|
||||
# [DEF:_find_legacy_config_path:Function]
|
||||
# @PURPOSE: Resolves the existing legacy config.json path from candidates.
|
||||
def _find_legacy_config_path(explicit_path: Optional[str]) -> Optional[Path]:
|
||||
with belief_scope("_find_legacy_config_path"):
|
||||
if explicit_path:
|
||||
p = Path(explicit_path)
|
||||
return p if p.exists() else None
|
||||
|
||||
candidates = [
|
||||
Path("backend/config.json"),
|
||||
Path("config.json"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
# [/DEF:_find_legacy_config_path:Function]
|
||||
|
||||
|
||||
# [DEF:_connect_sqlite:Function]
|
||||
# @PURPOSE: Opens a SQLite connection with row factory.
|
||||
def _connect_sqlite(path: Path) -> sqlite3.Connection:
|
||||
with belief_scope("_connect_sqlite"):
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
# [/DEF:_connect_sqlite:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_target_schema:Function]
|
||||
# @PURPOSE: Ensures required PostgreSQL tables exist before migration.
|
||||
def _ensure_target_schema(engine) -> None:
|
||||
with belief_scope("_ensure_target_schema"):
|
||||
stmts: Iterable[str] = (
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_configurations (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
environment_id TEXT NULL,
|
||||
started_at TIMESTAMPTZ NULL,
|
||||
finished_at TIMESTAMPTZ NULL,
|
||||
logs JSONB NULL,
|
||||
error TEXT NULL,
|
||||
result JSONB NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
params JSONB NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
level VARCHAR(16) NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'system',
|
||||
message TEXT NOT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
CONSTRAINT fk_task_logs_task
|
||||
FOREIGN KEY(task_id)
|
||||
REFERENCES task_records(id)
|
||||
ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_timestamp ON task_logs (task_id, timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_level ON task_logs (task_id, level)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_source ON task_logs (task_id, source)",
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'task_logs_id_seq'
|
||||
) THEN
|
||||
PERFORM 1;
|
||||
ELSE
|
||||
CREATE SEQUENCE task_logs_id_seq OWNED BY task_logs.id;
|
||||
END IF;
|
||||
END $$;
|
||||
""",
|
||||
"ALTER TABLE task_logs ALTER COLUMN id SET DEFAULT nextval('task_logs_id_seq')",
|
||||
)
|
||||
with engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
conn.execute(text(stmt))
|
||||
# [/DEF:_ensure_target_schema:Function]
|
||||
|
||||
|
||||
# [DEF:_migrate_config:Function]
|
||||
# @PURPOSE: Migrates legacy config.json into app_configurations(global).
|
||||
def _migrate_config(engine, legacy_config_path: Optional[Path]) -> int:
|
||||
with belief_scope("_migrate_config"):
|
||||
if legacy_config_path is None:
|
||||
logger.info("[_migrate_config][Action] No legacy config.json found, skipping")
|
||||
return 0
|
||||
|
||||
payload = json.loads(legacy_config_path.read_text(encoding="utf-8"))
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO app_configurations (id, payload, updated_at)
|
||||
VALUES ('global', CAST(:payload AS JSONB), NOW())
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
|
||||
"""
|
||||
),
|
||||
{"payload": json.dumps(payload, ensure_ascii=True)},
|
||||
)
|
||||
logger.info("[_migrate_config][Coherence:OK] Config migrated from %s", legacy_config_path)
|
||||
return 1
|
||||
# [/DEF:_migrate_config:Function]
|
||||
|
||||
|
||||
# [DEF:_migrate_tasks_and_logs:Function]
|
||||
# @PURPOSE: Migrates task_records and task_logs from SQLite into PostgreSQL.
|
||||
def _migrate_tasks_and_logs(engine, sqlite_conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
with belief_scope("_migrate_tasks_and_logs"):
|
||||
stats = {"task_records_total": 0, "task_records_inserted": 0, "task_logs_total": 0, "task_logs_inserted": 0}
|
||||
|
||||
rows = sqlite_conn.execute(
|
||||
"""
|
||||
SELECT id, type, status, environment_id, started_at, finished_at, logs, error, result, created_at, params
|
||||
FROM task_records
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
stats["task_records_total"] = len(rows)
|
||||
|
||||
with engine.begin() as conn:
|
||||
existing_env_ids = {
|
||||
row[0]
|
||||
for row in conn.execute(text("SELECT id FROM environments")).fetchall()
|
||||
}
|
||||
for row in rows:
|
||||
params_obj = _json_load_if_needed(row["params"])
|
||||
result_obj = _json_load_if_needed(row["result"])
|
||||
logs_obj = _json_load_if_needed(row["logs"])
|
||||
environment_id = row["environment_id"]
|
||||
if environment_id and environment_id not in existing_env_ids:
|
||||
# Legacy task may reference environments that were not migrated; keep task row and drop FK value.
|
||||
environment_id = None
|
||||
|
||||
res = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO task_records (
|
||||
id, type, status, environment_id, started_at, finished_at,
|
||||
logs, error, result, created_at, params
|
||||
) VALUES (
|
||||
:id, :type, :status, :environment_id, :started_at, :finished_at,
|
||||
CAST(:logs AS JSONB), :error, CAST(:result AS JSONB), :created_at, CAST(:params AS JSONB)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"status": row["status"],
|
||||
"environment_id": environment_id,
|
||||
"started_at": row["started_at"],
|
||||
"finished_at": row["finished_at"],
|
||||
"logs": json.dumps(logs_obj, ensure_ascii=True) if logs_obj is not None else None,
|
||||
"error": row["error"],
|
||||
"result": json.dumps(result_obj, ensure_ascii=True) if result_obj is not None else None,
|
||||
"created_at": row["created_at"],
|
||||
"params": json.dumps(params_obj, ensure_ascii=True) if params_obj is not None else None,
|
||||
},
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
stats["task_records_inserted"] += int(res.rowcount)
|
||||
|
||||
log_rows = sqlite_conn.execute(
|
||||
"""
|
||||
SELECT id, task_id, timestamp, level, source, message, metadata_json
|
||||
FROM task_logs
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
stats["task_logs_total"] = len(log_rows)
|
||||
|
||||
with engine.begin() as conn:
|
||||
for row in log_rows:
|
||||
# Preserve original IDs to keep migration idempotent.
|
||||
res = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO task_logs (id, task_id, timestamp, level, source, message, metadata_json)
|
||||
VALUES (:id, :task_id, :timestamp, :level, :source, :message, :metadata_json)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": row["id"],
|
||||
"task_id": row["task_id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"level": row["level"],
|
||||
"source": row["source"] or "system",
|
||||
"message": row["message"],
|
||||
"metadata_json": row["metadata_json"],
|
||||
},
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
stats["task_logs_inserted"] += int(res.rowcount)
|
||||
|
||||
# Ensure sequence is aligned after explicit id inserts.
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT setval(
|
||||
'task_logs_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM task_logs), 1),
|
||||
TRUE
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[_migrate_tasks_and_logs][Coherence:OK] task_records=%s/%s task_logs=%s/%s",
|
||||
stats["task_records_inserted"],
|
||||
stats["task_records_total"],
|
||||
stats["task_logs_inserted"],
|
||||
stats["task_logs_total"],
|
||||
)
|
||||
return stats
|
||||
# [/DEF:_migrate_tasks_and_logs:Function]
|
||||
|
||||
|
||||
# [DEF:run_migration:Function]
|
||||
# @PURPOSE: Orchestrates migration from SQLite/file to PostgreSQL.
|
||||
def run_migration(sqlite_path: Path, target_url: str, legacy_config_path: Optional[Path]) -> Dict[str, int]:
|
||||
with belief_scope("run_migration"):
|
||||
logger.info("[run_migration][Entry] sqlite=%s target=%s", sqlite_path, target_url)
|
||||
if not sqlite_path.exists():
|
||||
raise FileNotFoundError(f"SQLite source not found: {sqlite_path}")
|
||||
|
||||
sqlite_conn = _connect_sqlite(sqlite_path)
|
||||
engine = create_engine(target_url, pool_pre_ping=True)
|
||||
try:
|
||||
_ensure_target_schema(engine)
|
||||
config_upserted = _migrate_config(engine, legacy_config_path)
|
||||
stats = _migrate_tasks_and_logs(engine, sqlite_conn)
|
||||
stats["config_upserted"] = config_upserted
|
||||
return stats
|
||||
finally:
|
||||
sqlite_conn.close()
|
||||
# [/DEF:run_migration:Function]
|
||||
|
||||
|
||||
# [DEF:main:Function]
|
||||
# @PURPOSE: CLI entrypoint.
|
||||
def main() -> int:
|
||||
with belief_scope("main"):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Migrate legacy config.json and task logs from SQLite to PostgreSQL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sqlite-path",
|
||||
default="backend/tasks.db",
|
||||
help="Path to source SQLite DB with task_records/task_logs (default: backend/tasks.db).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-url",
|
||||
default=DEFAULT_TARGET_URL,
|
||||
help="Target PostgreSQL SQLAlchemy URL (default: DATABASE_URL/POSTGRES_URL env).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config-path",
|
||||
default=None,
|
||||
help="Optional path to legacy config.json (auto-detected when omitted).",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
sqlite_path = Path(args.sqlite_path)
|
||||
legacy_config_path = _find_legacy_config_path(args.config_path)
|
||||
try:
|
||||
stats = run_migration(sqlite_path=sqlite_path, target_url=args.target_url, legacy_config_path=legacy_config_path)
|
||||
print("Migration completed.")
|
||||
print(json.dumps(stats, indent=2))
|
||||
return 0
|
||||
except (SQLAlchemyError, OSError, sqlite3.Error, ValueError) as e:
|
||||
logger.error("[main][Coherence:Failed] Migration failed: %s", e)
|
||||
print(f"Migration failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
# [/DEF:main:Function]
|
||||
|
||||
# [/DEF:backend.src.scripts.migrate_sqlite_to_postgres:Module]
|
||||
@@ -18,3 +18,4 @@ def __getattr__(name):
|
||||
from .resource_service import ResourceService
|
||||
return ResourceService
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
# [/DEF:backend.src.services:Module]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# [DEF:backend.src.services.__tests__.test_resource_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
||||
# @PURPOSE: Unit tests for ResourceService
|
||||
# @LAYER: Service
|
||||
# @RELATION: TESTS -> backend.src.services.resource_service
|
||||
# @RELATION: VERIFIES -> ResourceService
|
||||
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -11,6 +13,7 @@ from datetime import datetime
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_with_status:Function]
|
||||
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
||||
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
||||
# @PRE: SupersetClient returns dashboard list
|
||||
# @POST: Each dashboard has git_status and last_task fields
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# [DEF:backend.tests.test_report_normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, normalizer, fallback
|
||||
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.services.reports.normalizer import normalize_task_report
|
||||
|
||||
|
||||
def test_unknown_type_maps_to_unknown_profile():
|
||||
task = Task(
|
||||
id="unknown-1",
|
||||
plugin_id="custom-unmapped-plugin",
|
||||
status=TaskStatus.FAILED,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result={"error_message": "Unexpected plugin payload"},
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "unknown"
|
||||
assert report.summary
|
||||
assert report.error_context is not None
|
||||
|
||||
|
||||
def test_partial_payload_keeps_report_visible_with_placeholders():
|
||||
task = Task(
|
||||
id="partial-1",
|
||||
plugin_id="superset-backup",
|
||||
status=TaskStatus.SUCCESS,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result=None,
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "backup"
|
||||
assert report.details is not None
|
||||
assert "result" in report.details
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_report_normalizer:Module]
|
||||
152
backend/src/services/reports/normalizer.py
Normal file
152
backend/src/services/reports/normalizer.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# [DEF:backend.src.services.reports.normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, normalization, tasks, fallback
|
||||
# @PURPOSE: Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models.Task
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.type_profiles
|
||||
# @INVARIANT: Unknown task types and partial payloads remain visible via fallback mapping.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...core.task_manager.models import Task, TaskStatus
|
||||
from ...models.report import ErrorContext, ReportStatus, TaskReport
|
||||
from .type_profiles import get_type_profile, resolve_task_type
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:status_to_report_status:Function]
|
||||
# @PURPOSE: Normalize internal task status to canonical report status.
|
||||
# @PRE: status may be known or unknown string/enum value.
|
||||
# @POST: Always returns one of canonical ReportStatus values.
|
||||
# @PARAM: status (Any) - Internal task status value.
|
||||
# @RETURN: ReportStatus - Canonical report status.
|
||||
def status_to_report_status(status: Any) -> ReportStatus:
|
||||
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
|
||||
if raw == TaskStatus.SUCCESS.value:
|
||||
return ReportStatus.SUCCESS
|
||||
if raw == TaskStatus.FAILED.value:
|
||||
return ReportStatus.FAILED
|
||||
if raw in {TaskStatus.PENDING.value, TaskStatus.RUNNING.value, TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
|
||||
return ReportStatus.IN_PROGRESS
|
||||
return ReportStatus.PARTIAL
|
||||
# [/DEF:status_to_report_status:Function]
|
||||
|
||||
|
||||
# [DEF:build_summary:Function]
|
||||
# @PURPOSE: Build deterministic user-facing summary from task payload and status.
|
||||
# @PRE: report_status is canonical; plugin_id may be unknown.
|
||||
# @POST: Returns non-empty summary text.
|
||||
# @PARAM: task (Task) - Source task object.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: str - Normalized summary.
|
||||
def build_summary(task: Task, report_status: ReportStatus) -> str:
|
||||
result = task.result
|
||||
if isinstance(result, dict):
|
||||
for key in ("summary", "message", "status_message", "description"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if report_status == ReportStatus.SUCCESS:
|
||||
return "Task completed successfully"
|
||||
if report_status == ReportStatus.FAILED:
|
||||
return "Task failed"
|
||||
if report_status == ReportStatus.IN_PROGRESS:
|
||||
return "Task is in progress"
|
||||
return "Task completed with partial data"
|
||||
# [/DEF:build_summary:Function]
|
||||
|
||||
|
||||
# [DEF:extract_error_context:Function]
|
||||
# @PURPOSE: Extract normalized error context and next actions for failed/partial reports.
|
||||
# @PRE: task is a valid Task object.
|
||||
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: Optional[ErrorContext] - Error context block.
|
||||
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
|
||||
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
return None
|
||||
|
||||
result = task.result if isinstance(task.result, dict) else {}
|
||||
message = None
|
||||
code = None
|
||||
next_actions = []
|
||||
|
||||
if isinstance(result.get("error"), dict):
|
||||
error_obj = result.get("error", {})
|
||||
message = error_obj.get("message") or message
|
||||
code = error_obj.get("code") or code
|
||||
actions = error_obj.get("next_actions")
|
||||
if isinstance(actions, list):
|
||||
next_actions = [str(action) for action in actions if str(action).strip()]
|
||||
|
||||
if not message:
|
||||
message = result.get("error_message") if isinstance(result.get("error_message"), str) else None
|
||||
|
||||
if not message:
|
||||
for log in reversed(task.logs):
|
||||
if str(log.level).upper() == "ERROR" and log.message:
|
||||
message = log.message
|
||||
break
|
||||
|
||||
if not message:
|
||||
message = "Not provided"
|
||||
|
||||
if not next_actions:
|
||||
next_actions = ["Review task diagnostics", "Retry the operation"]
|
||||
|
||||
return ErrorContext(code=code, message=message, next_actions=next_actions)
|
||||
# [/DEF:extract_error_context:Function]
|
||||
|
||||
|
||||
# [DEF:normalize_task_report:Function]
|
||||
# @PURPOSE: Convert one Task to canonical TaskReport envelope.
|
||||
# @PRE: task has valid id and plugin_id fields.
|
||||
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @RETURN: TaskReport - Canonical normalized report.
|
||||
def normalize_task_report(task: Task) -> TaskReport:
|
||||
task_type = resolve_task_type(task.plugin_id)
|
||||
report_status = status_to_report_status(task.status)
|
||||
profile = get_type_profile(task_type)
|
||||
|
||||
started_at = task.started_at if isinstance(task.started_at, datetime) else None
|
||||
updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None
|
||||
if not updated_at:
|
||||
updated_at = started_at or datetime.utcnow()
|
||||
|
||||
details: Dict[str, Any] = {
|
||||
"profile": {
|
||||
"display_label": profile.get("display_label"),
|
||||
"visual_variant": profile.get("visual_variant"),
|
||||
"icon_token": profile.get("icon_token"),
|
||||
"emphasis_rules": profile.get("emphasis_rules", []),
|
||||
},
|
||||
"result": task.result if task.result is not None else {"note": "Not provided"},
|
||||
}
|
||||
|
||||
source_ref: Dict[str, Any] = {}
|
||||
if isinstance(task.params, dict):
|
||||
for key in ("environment_id", "source_env_id", "target_env_id", "dashboard_id", "dataset_id", "resource_id"):
|
||||
if key in task.params:
|
||||
source_ref[key] = task.params.get(key)
|
||||
|
||||
return TaskReport(
|
||||
report_id=task.id,
|
||||
task_id=task.id,
|
||||
task_type=task_type,
|
||||
status=report_status,
|
||||
started_at=started_at,
|
||||
updated_at=updated_at,
|
||||
summary=build_summary(task, report_status),
|
||||
details=details,
|
||||
error_context=extract_error_context(task, report_status),
|
||||
source_ref=source_ref or None,
|
||||
)
|
||||
# [/DEF:normalize_task_report:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.normalizer:Module]
|
||||
195
backend/src/services/reports/report_service.py
Normal file
195
backend/src/services/reports/report_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# [DEF:backend.src.services.reports.report_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, service, aggregation, filtering, pagination, detail
|
||||
# @PURPOSE: Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: List responses are deterministic and include applied filter echo metadata.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskReport, TaskType
|
||||
from .normalizer import normalize_task_report
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ReportsService:Class]
|
||||
# @PURPOSE: Service layer for list/detail report retrieval and normalization.
|
||||
# @TIER: CRITICAL
|
||||
# @PRE: TaskManager dependency is initialized.
|
||||
# @POST: Provides deterministic list/detail report responses.
|
||||
# @INVARIANT: Service methods are read-only over task history source.
|
||||
class ReportsService:
|
||||
# [DEF:__init__:Function]
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Initialize service with TaskManager dependency.
|
||||
# @PRE: task_manager is a live TaskManager instance.
|
||||
# @POST: self.task_manager is assigned and ready for read operations.
|
||||
# @INVARIANT: Constructor performs no task mutations.
|
||||
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
|
||||
def __init__(self, task_manager: TaskManager):
|
||||
self.task_manager = task_manager
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_load_normalized_reports:Function]
|
||||
# @PURPOSE: Build normalized reports from all available tasks.
|
||||
# @PRE: Task manager returns iterable task history records.
|
||||
# @POST: Returns normalized report list preserving source cardinality.
|
||||
# @INVARIANT: Every returned item is a TaskReport.
|
||||
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
|
||||
def _load_normalized_reports(self) -> List[TaskReport]:
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
reports = [normalize_task_report(task) for task in tasks]
|
||||
return reports
|
||||
# [/DEF:_load_normalized_reports:Function]
|
||||
|
||||
# [DEF:_to_utc_datetime:Function]
|
||||
# @PURPOSE: Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
|
||||
# @PRE: value is either datetime or None.
|
||||
# @POST: Returns UTC-aware datetime or None.
|
||||
# @INVARIANT: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
|
||||
# @PARAM: value (Optional[datetime]) - Source datetime value.
|
||||
# @RETURN: Optional[datetime] - UTC-aware datetime or None.
|
||||
def _to_utc_datetime(self, value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
# [/DEF:_to_utc_datetime:Function]
|
||||
|
||||
# [DEF:_datetime_sort_key:Function]
|
||||
# @PURPOSE: Produce stable numeric sort key for report timestamps.
|
||||
# @PRE: report contains updated_at datetime.
|
||||
# @POST: Returns float timestamp suitable for deterministic sorting.
|
||||
# @INVARIANT: Mixed naive/aware datetimes never raise TypeError.
|
||||
# @PARAM: report (TaskReport) - Report item.
|
||||
# @RETURN: float - UTC timestamp key.
|
||||
def _datetime_sort_key(self, report: TaskReport) -> float:
|
||||
updated = self._to_utc_datetime(report.updated_at)
|
||||
if updated is None:
|
||||
return 0.0
|
||||
return updated.timestamp()
|
||||
# [/DEF:_datetime_sort_key:Function]
|
||||
|
||||
# [DEF:_matches_query:Function]
|
||||
# @PURPOSE: Apply query filtering to a report.
|
||||
# @PRE: report and query are normalized schema instances.
|
||||
# @POST: Returns True iff report satisfies all active query filters.
|
||||
# @INVARIANT: Filter evaluation is side-effect free.
|
||||
# @PARAM: report (TaskReport) - Candidate report.
|
||||
# @PARAM: query (ReportQuery) - Applied query.
|
||||
# @RETURN: bool - True if report matches all filters.
|
||||
def _matches_query(self, report: TaskReport, query: ReportQuery) -> bool:
|
||||
if query.task_types and report.task_type not in query.task_types:
|
||||
return False
|
||||
if query.statuses and report.status not in query.statuses:
|
||||
return False
|
||||
report_updated_at = self._to_utc_datetime(report.updated_at)
|
||||
query_time_from = self._to_utc_datetime(query.time_from)
|
||||
query_time_to = self._to_utc_datetime(query.time_to)
|
||||
|
||||
if query_time_from and report_updated_at and report_updated_at < query_time_from:
|
||||
return False
|
||||
if query_time_to and report_updated_at and report_updated_at > query_time_to:
|
||||
return False
|
||||
if query.search:
|
||||
needle = query.search.lower()
|
||||
haystack = f"{report.summary} {report.task_type.value} {report.status.value}".lower()
|
||||
if needle not in haystack:
|
||||
return False
|
||||
return True
|
||||
# [/DEF:_matches_query:Function]
|
||||
|
||||
# [DEF:_sort_reports:Function]
|
||||
# @PURPOSE: Sort reports deterministically according to query settings.
|
||||
# @PRE: reports contains only TaskReport items.
|
||||
# @POST: Returns reports ordered by selected sort field and order.
|
||||
# @INVARIANT: Sorting criteria are deterministic for equal input.
|
||||
# @PARAM: reports (List[TaskReport]) - Filtered reports.
|
||||
# @PARAM: query (ReportQuery) - Sort config.
|
||||
# @RETURN: List[TaskReport] - Sorted reports.
|
||||
def _sort_reports(self, reports: List[TaskReport], query: ReportQuery) -> List[TaskReport]:
|
||||
reverse = query.sort_order == "desc"
|
||||
|
||||
if query.sort_by == "status":
|
||||
reports.sort(key=lambda item: item.status.value, reverse=reverse)
|
||||
elif query.sort_by == "task_type":
|
||||
reports.sort(key=lambda item: item.task_type.value, reverse=reverse)
|
||||
else:
|
||||
reports.sort(key=self._datetime_sort_key, reverse=reverse)
|
||||
|
||||
return reports
|
||||
# [/DEF:_sort_reports:Function]
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection.
|
||||
# @PRE: query has passed schema validation.
|
||||
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @PARAM: query (ReportQuery) - List filters and pagination.
|
||||
# @RETURN: ReportCollection - Paginated unified reports payload.
|
||||
def list_reports(self, query: ReportQuery) -> ReportCollection:
|
||||
reports = self._load_normalized_reports()
|
||||
filtered = [report for report in reports if self._matches_query(report, query)]
|
||||
sorted_reports = self._sort_reports(filtered, query)
|
||||
|
||||
total = len(sorted_reports)
|
||||
start = (query.page - 1) * query.page_size
|
||||
end = start + query.page_size
|
||||
items = sorted_reports[start:end]
|
||||
has_next = end < total
|
||||
|
||||
return ReportCollection(
|
||||
items=items,
|
||||
total=total,
|
||||
page=query.page,
|
||||
page_size=query.page_size,
|
||||
has_next=has_next,
|
||||
applied_filters=query,
|
||||
)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
|
||||
# @PRE: report_id exists in normalized report set.
|
||||
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
|
||||
# @PARAM: report_id (str) - Stable report identifier.
|
||||
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
|
||||
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
|
||||
reports = self._load_normalized_reports()
|
||||
target = next((report for report in reports if report.report_id == report_id), None)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
timeline = []
|
||||
if target.started_at:
|
||||
timeline.append({"event": "started", "at": target.started_at.isoformat()})
|
||||
timeline.append({"event": "updated", "at": target.updated_at.isoformat()})
|
||||
|
||||
diagnostics = target.details or {}
|
||||
if not diagnostics:
|
||||
diagnostics = {"note": "Not provided"}
|
||||
if target.error_context:
|
||||
diagnostics["error_context"] = target.error_context.model_dump()
|
||||
|
||||
next_actions = []
|
||||
if target.error_context and target.error_context.next_actions:
|
||||
next_actions = target.error_context.next_actions
|
||||
elif target.status in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
next_actions = ["Review diagnostics", "Retry task if applicable"]
|
||||
|
||||
return ReportDetailView(
|
||||
report=target,
|
||||
timeline=timeline,
|
||||
diagnostics=diagnostics,
|
||||
next_actions=next_actions,
|
||||
)
|
||||
# [/DEF:get_report_detail:Function]
|
||||
# [/DEF:ReportsService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.reports.report_service:Module]
|
||||
91
backend/src/services/reports/type_profiles.py
Normal file
91
backend/src/services/reports/type_profiles.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# [DEF:backend.src.services.reports.type_profiles:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, type_profiles, normalization, fallback
|
||||
# @PURPOSE: Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report.TaskType
|
||||
# @INVARIANT: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...models.report import TaskType
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
# @PURPOSE: Maps plugin identifiers to normalized report task types.
|
||||
PLUGIN_TO_TASK_TYPE: Dict[str, TaskType] = {
|
||||
"llm_dashboard_validation": TaskType.LLM_VERIFICATION,
|
||||
"superset-backup": TaskType.BACKUP,
|
||||
"superset-migration": TaskType.MIGRATION,
|
||||
"documentation": TaskType.DOCUMENTATION,
|
||||
}
|
||||
# [/DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
|
||||
# [DEF:TASK_TYPE_PROFILES:Data]
|
||||
# @PURPOSE: Profile metadata registry for each normalized task type.
|
||||
TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
|
||||
TaskType.LLM_VERIFICATION: {
|
||||
"display_label": "LLM Verification",
|
||||
"visual_variant": "llm",
|
||||
"icon_token": "sparkles",
|
||||
"emphasis_rules": ["summary", "status", "next_actions"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.BACKUP: {
|
||||
"display_label": "Backup",
|
||||
"visual_variant": "backup",
|
||||
"icon_token": "archive",
|
||||
"emphasis_rules": ["summary", "status", "updated_at"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.MIGRATION: {
|
||||
"display_label": "Migration",
|
||||
"visual_variant": "migration",
|
||||
"icon_token": "shuffle",
|
||||
"emphasis_rules": ["summary", "status", "error_context"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.DOCUMENTATION: {
|
||||
"display_label": "Documentation",
|
||||
"visual_variant": "documentation",
|
||||
"icon_token": "file-text",
|
||||
"emphasis_rules": ["summary", "status", "details"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.UNKNOWN: {
|
||||
"display_label": "Other / Unknown",
|
||||
"visual_variant": "unknown",
|
||||
"icon_token": "help-circle",
|
||||
"emphasis_rules": ["summary", "status"],
|
||||
"fallback": True,
|
||||
},
|
||||
}
|
||||
# [/DEF:TASK_TYPE_PROFILES:Data]
|
||||
|
||||
|
||||
# [DEF:resolve_task_type:Function]
|
||||
# @PURPOSE: Resolve canonical task type from plugin/task identifier with guaranteed fallback.
|
||||
# @PRE: plugin_id may be None or unknown.
|
||||
# @POST: Always returns one of TaskType enum values.
|
||||
# @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record.
|
||||
# @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback.
|
||||
def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
|
||||
normalized = (plugin_id or "").strip()
|
||||
if not normalized:
|
||||
return TaskType.UNKNOWN
|
||||
return PLUGIN_TO_TASK_TYPE.get(normalized, TaskType.UNKNOWN)
|
||||
# [/DEF:resolve_task_type:Function]
|
||||
|
||||
|
||||
# [DEF:get_type_profile:Function]
|
||||
# @PURPOSE: Return deterministic profile metadata for a task type.
|
||||
# @PRE: task_type may be known or unknown.
|
||||
# @POST: Returns a profile dict and never raises for unknown types.
|
||||
# @PARAM: task_type (TaskType) - Canonical task type.
|
||||
# @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts.
|
||||
def get_type_profile(task_type: TaskType) -> Dict[str, Any]:
|
||||
return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN])
|
||||
# [/DEF:get_type_profile:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.type_profiles:Module]
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
81
backend/tests/fixtures/reports/fixtures_reports.json
vendored
Normal file
81
backend/tests/fixtures/reports/fixtures_reports.json
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"mixed_task_reports": {
|
||||
"description": "Mixed reports across all supported task types",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-001",
|
||||
"task_id": "task-001",
|
||||
"task_type": "llm_verification",
|
||||
"status": "success",
|
||||
"started_at": "2026-02-22T09:00:00Z",
|
||||
"updated_at": "2026-02-22T09:00:30Z",
|
||||
"summary": "LLM verification completed",
|
||||
"details": {
|
||||
"checks_performed": 12,
|
||||
"issues_found": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-002",
|
||||
"task_id": "task-002",
|
||||
"task_type": "backup",
|
||||
"status": "failed",
|
||||
"started_at": "2026-02-22T09:10:00Z",
|
||||
"updated_at": "2026-02-22T09:11:00Z",
|
||||
"summary": "Backup failed due to storage limit",
|
||||
"error_context": {
|
||||
"message": "Not enough disk space",
|
||||
"next_actions": ["Free storage", "Retry backup"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-003",
|
||||
"task_id": "task-003",
|
||||
"task_type": "migration",
|
||||
"status": "in_progress",
|
||||
"started_at": "2026-02-22T09:20:00Z",
|
||||
"updated_at": "2026-02-22T09:21:00Z",
|
||||
"summary": "Migration running",
|
||||
"details": {
|
||||
"progress_percent": 42
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-004",
|
||||
"task_id": "task-004",
|
||||
"task_type": "documentation",
|
||||
"status": "partial",
|
||||
"started_at": "2026-02-22T09:30:00Z",
|
||||
"updated_at": "2026-02-22T09:31:00Z",
|
||||
"summary": "Documentation generated with partial coverage",
|
||||
"error_context": {
|
||||
"message": "Missing metadata for 3 columns",
|
||||
"next_actions": ["Review missing metadata"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unknown_type_partial_payload": {
|
||||
"description": "Unknown type and partial payload fallback coverage",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-unknown-001",
|
||||
"task_id": "task-unknown-001",
|
||||
"task_type": "unknown",
|
||||
"status": "failed",
|
||||
"updated_at": "2026-02-22T10:00:00Z",
|
||||
"summary": "Unknown task type failed",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"report_id": "rep-partial-001",
|
||||
"task_id": "task-partial-001",
|
||||
"task_type": "backup",
|
||||
"status": "success",
|
||||
"updated_at": "2026-02-22T10:05:00Z",
|
||||
"summary": "Backup completed",
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
build.sh
Executable file
29
build.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is not installed or not in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
else
|
||||
echo "Error: docker compose is not available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/2] Building project images..."
|
||||
"${COMPOSE_CMD[@]}" build
|
||||
|
||||
echo "[2/2] Starting Docker services..."
|
||||
"${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
echo "Done. Services are running."
|
||||
echo "Use '${COMPOSE_CMD[*]} ps' to check status and '${COMPOSE_CMD[*]} logs -f' to stream logs."
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
db:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
container_name: ss_tools_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ss_tools
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d ss_tools"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ss_tools_app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
TASKS_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
AUTH_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
BACKEND_PORT: 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json
|
||||
- ./backups:/app/backups
|
||||
- ./backend/git_repos:/app/backend/git_repos
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -17,10 +17,11 @@ The application moves from a **Task-Centric** model (where users navigate to "Mi
|
||||
`[Home] [Migration] [Git Manager] [Mapper] [Settings] [Logout]`
|
||||
|
||||
**New Menu:**
|
||||
`[Superset Manager] [Dashboards] [Datasets] [Storage] | [Activity (0)] [Settings] [User]`
|
||||
`[Superset Manager] [Dashboards] [Datasets] [Reports] [Storage] | [Activity (0)] [Settings] [User]`
|
||||
|
||||
* **Dashboards**: Main hub for all dashboard operations (Migrate, Backup, Git).
|
||||
* **Datasets**: Hub for dataset documentation and mapping.
|
||||
* **Reports**: Unified center for all task outcomes with type-distinct visual profiles and detail diagnostics.
|
||||
* **Storage**: File management (Backups, Repositories).
|
||||
* **Activity**: Global indicator of running tasks. Clicking it opens the Task Drawer.
|
||||
|
||||
|
||||
@@ -39,6 +39,23 @@ The settings API is available at `/settings`:
|
||||
|
||||
The settings page is located at `frontend/src/pages/Settings.svelte`. It provides forms for managing global settings and Superset environments.
|
||||
|
||||
## Reports Center
|
||||
|
||||
Unified reports are available at [`/reports`](frontend/src/routes/reports/+page.svelte) and use the backend API at [`/api/reports`](backend/src/api/routes/reports.py) and [`/api/reports/{report_id}`](backend/src/api/routes/reports.py).
|
||||
|
||||
### What operators can do
|
||||
|
||||
- View all task outcomes (LLM verification, backup, migration, documentation) in one list.
|
||||
- Filter by type and status.
|
||||
- Open report detail with diagnostics and recommended next actions.
|
||||
- Continue working even for unknown task types and partial payloads (explicit placeholders are shown instead of hidden data).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- If report list is empty, verify tasks exist and clear filters.
|
||||
- If report detail is not found (404), confirm the selected report still exists in task history.
|
||||
- If report API tests fail during local execution with database connectivity errors, ensure the configured DB is reachable or run in an environment with available test DB services.
|
||||
|
||||
## Integration
|
||||
|
||||
Existing plugins and utilities use the `ConfigManager` to fetch configuration:
|
||||
|
||||
@@ -11,19 +11,18 @@
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { DashboardMetadata } from '../types/dashboard';
|
||||
import { t } from '../lib/i18n';
|
||||
import { Button, Input } from '../lib/ui';
|
||||
import GitManager from './git/GitManager.svelte';
|
||||
import { api } from '../lib/api';
|
||||
import { addToast as toast } from '../lib/toasts.js';
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { DashboardMetadata } from "../types/dashboard";
|
||||
import { t } from "../lib/i18n";
|
||||
import { Button, Input } from "../lib/ui";
|
||||
import GitManager from "./git/GitManager.svelte";
|
||||
import { api } from "../lib/api";
|
||||
import { addToast as toast } from "../lib/toasts.js";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboards: DashboardMetadata[] = [];
|
||||
export let selectedIds: number[] = [];
|
||||
export let environmentId: string = "ss1";
|
||||
let { dashboards = [], selectedIds = [], environmentId = "ss1" } = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
@@ -47,71 +46,85 @@
|
||||
*/
|
||||
async function handleValidate(dashboard: DashboardMetadata) {
|
||||
if (validatingIds.has(dashboard.id)) return;
|
||||
|
||||
|
||||
validatingIds.add(dashboard.id);
|
||||
validatingIds = validatingIds; // Trigger reactivity
|
||||
|
||||
try {
|
||||
// TODO: Get provider_id from settings or prompt user
|
||||
// For now, we assume a default provider or let the backend handle it if possible,
|
||||
// but the plugin requires provider_id.
|
||||
// In a real implementation, we might open a modal to select provider if not configured globally.
|
||||
// Or we pick the first active one.
|
||||
|
||||
// Fetch active provider first
|
||||
const providers = await api.fetchApi('/llm/providers');
|
||||
const activeProvider = providers.find((p: any) => p.is_active);
|
||||
|
||||
if (!activeProvider) {
|
||||
toast('No active LLM provider found. Please configure one in settings.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.postApi('/tasks', {
|
||||
plugin_id: 'llm_dashboard_validation',
|
||||
params: {
|
||||
dashboard_id: dashboard.id.toString(),
|
||||
environment_id: environmentId,
|
||||
provider_id: activeProvider.id
|
||||
}
|
||||
});
|
||||
|
||||
toast('Validation task started', 'success');
|
||||
try {
|
||||
// TODO: Get provider_id from settings or prompt user
|
||||
// For now, we assume a default provider or let the backend handle it if possible,
|
||||
// but the plugin requires provider_id.
|
||||
// In a real implementation, we might open a modal to select provider if not configured globally.
|
||||
// Or we pick the first active one.
|
||||
|
||||
// Fetch active provider first
|
||||
const providers = await api.fetchApi("/llm/providers");
|
||||
const activeProvider = providers.find((p: any) => p.is_active);
|
||||
|
||||
if (!activeProvider) {
|
||||
toast(
|
||||
"No active LLM provider found. Please configure one in settings.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await api.postApi("/tasks", {
|
||||
plugin_id: "llm_dashboard_validation",
|
||||
params: {
|
||||
dashboard_id: dashboard.id.toString(),
|
||||
environment_id: environmentId,
|
||||
provider_id: activeProvider.id,
|
||||
},
|
||||
});
|
||||
|
||||
toast("Validation task started", "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message || 'Validation failed to start', 'error');
|
||||
toast(e.message || "Validation failed to start", "error");
|
||||
} finally {
|
||||
validatingIds.delete(dashboard.id);
|
||||
validatingIds = validatingIds;
|
||||
validatingIds.delete(dashboard.id);
|
||||
validatingIds = validatingIds;
|
||||
}
|
||||
}
|
||||
// [/DEF:handleValidate:Function]
|
||||
|
||||
// [SECTION: DERIVED]
|
||||
$: filteredDashboards = dashboards.filter(d =>
|
||||
d.title.toLowerCase().includes(filterText.toLowerCase())
|
||||
let filteredDashboards = $derived(
|
||||
dashboards.filter((d) =>
|
||||
d.title.toLowerCase().includes(filterText.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
$: sortedDashboards = [...filteredDashboards].sort((a, b) => {
|
||||
let aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
if (sortColumn === "id") {
|
||||
aVal = Number(aVal);
|
||||
bVal = Number(bVal);
|
||||
}
|
||||
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
$: paginatedDashboards = sortedDashboards.slice(
|
||||
currentPage * pageSize,
|
||||
(currentPage + 1) * pageSize
|
||||
let sortedDashboards = $derived(
|
||||
[...filteredDashboards].sort((a, b) => {
|
||||
let aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
if (sortColumn === "id") {
|
||||
aVal = Number(aVal);
|
||||
bVal = Number(bVal);
|
||||
}
|
||||
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
|
||||
$: totalPages = Math.ceil(sortedDashboards.length / pageSize);
|
||||
let paginatedDashboards = $derived(
|
||||
sortedDashboards.slice(
|
||||
currentPage * pageSize,
|
||||
(currentPage + 1) * pageSize,
|
||||
),
|
||||
);
|
||||
|
||||
$: allSelected = paginatedDashboards.length > 0 && paginatedDashboards.every(d => selectedIds.includes(d.id));
|
||||
$: someSelected = paginatedDashboards.some(d => selectedIds.includes(d.id));
|
||||
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
|
||||
|
||||
let allSelected = $derived(
|
||||
paginatedDashboards.length > 0 &&
|
||||
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
|
||||
);
|
||||
let someSelected = $derived(
|
||||
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
|
||||
);
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: EVENTS]
|
||||
@@ -141,10 +154,10 @@
|
||||
if (checked) {
|
||||
if (!newSelected.includes(id)) newSelected.push(id);
|
||||
} else {
|
||||
newSelected = newSelected.filter(sid => sid !== id);
|
||||
newSelected = newSelected.filter((sid) => sid !== id);
|
||||
}
|
||||
selectedIds = newSelected;
|
||||
dispatch('selectionChanged', newSelected);
|
||||
dispatch("selectionChanged", newSelected);
|
||||
}
|
||||
// [/DEF:handleSelectionChange:Function]
|
||||
|
||||
@@ -155,16 +168,16 @@
|
||||
function handleSelectAll(checked: boolean) {
|
||||
let newSelected = [...selectedIds];
|
||||
if (checked) {
|
||||
paginatedDashboards.forEach(d => {
|
||||
paginatedDashboards.forEach((d) => {
|
||||
if (!newSelected.includes(d.id)) newSelected.push(d.id);
|
||||
});
|
||||
} else {
|
||||
paginatedDashboards.forEach(d => {
|
||||
newSelected = newSelected.filter(sid => sid !== d.id);
|
||||
paginatedDashboards.forEach((d) => {
|
||||
newSelected = newSelected.filter((sid) => sid !== d.id);
|
||||
});
|
||||
}
|
||||
selectedIds = newSelected;
|
||||
dispatch('selectionChanged', newSelected);
|
||||
dispatch("selectionChanged", newSelected);
|
||||
}
|
||||
// [/DEF:handleSelectAll:Function]
|
||||
|
||||
@@ -189,17 +202,13 @@
|
||||
showGitManager = true;
|
||||
}
|
||||
// [/DEF:openGit:Function]
|
||||
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="dashboard-grid">
|
||||
<!-- Filter Input -->
|
||||
<div class="mb-6">
|
||||
<Input
|
||||
bind:value={filterText}
|
||||
placeholder={$t.dashboard.search}
|
||||
/>
|
||||
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
|
||||
</div>
|
||||
|
||||
<!-- Grid/Table -->
|
||||
@@ -212,21 +221,52 @@
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected && !allSelected}
|
||||
on:change={(e) => handleSelectAll((e.target as HTMLInputElement).checked)}
|
||||
on:change={(e) =>
|
||||
handleSelectAll((e.target as HTMLInputElement).checked)}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('title')}>
|
||||
{$t.dashboard.title} {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
on:click={() => handleSort("title")}
|
||||
>
|
||||
{$t.dashboard.title}
|
||||
{sortColumn === "title"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('last_modified')}>
|
||||
{$t.dashboard.last_modified} {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
on:click={() => handleSort("last_modified")}
|
||||
>
|
||||
{$t.dashboard.last_modified}
|
||||
{sortColumn === "last_modified"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('status')}>
|
||||
{$t.dashboard.status} {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
on:click={() => handleSort("status")}
|
||||
>
|
||||
{$t.dashboard.status}
|
||||
{sortColumn === "status"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.validation}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.git}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>{$t.dashboard.validation}</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>{$t.dashboard.git}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@@ -236,14 +276,28 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(dashboard.id)}
|
||||
on:change={(e) => handleSelectionChange(dashboard.id, (e.target as HTMLInputElement).checked)}
|
||||
on:change={(e) =>
|
||||
handleSelectionChange(
|
||||
dashboard.id,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{dashboard.title}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(dashboard.last_modified).toLocaleDateString()}</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
|
||||
>{dashboard.title}</td
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
|
||||
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status ===
|
||||
'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{dashboard.status}
|
||||
</span>
|
||||
</td>
|
||||
@@ -255,7 +309,7 @@
|
||||
disabled={validatingIds.has(dashboard.id)}
|
||||
class="text-purple-600 hover:text-purple-900"
|
||||
>
|
||||
{validatingIds.has(dashboard.id) ? 'Validating...' : 'Validate'}
|
||||
{validatingIds.has(dashboard.id) ? "Validating..." : "Validate"}
|
||||
</Button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@@ -278,9 +332,15 @@
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="text-sm text-gray-500">
|
||||
{($t.dashboard?.showing || "")
|
||||
.replace('{start}', (currentPage * pageSize + 1).toString())
|
||||
.replace('{end}', Math.min((currentPage + 1) * pageSize, sortedDashboards.length).toString())
|
||||
.replace('{total}', sortedDashboards.length.toString())}
|
||||
.replace("{start}", (currentPage * pageSize + 1).toString())
|
||||
.replace(
|
||||
"{end}",
|
||||
Math.min(
|
||||
(currentPage + 1) * pageSize,
|
||||
sortedDashboards.length,
|
||||
).toString(),
|
||||
)
|
||||
.replace("{total}", sortedDashboards.length.toString())}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -313,8 +373,4 @@
|
||||
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:DashboardGrid:Component] -->
|
||||
<!-- [/DEF:DashboardGrid:Component] -->
|
||||
|
||||
@@ -1,92 +1,95 @@
|
||||
<!-- [DEF:DynamicForm:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: form, schema, dynamic, json-schema
|
||||
@PURPOSE: Generates a form dynamically based on a JSON schema.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
|
||||
|
||||
@PROPS:
|
||||
- schema: Object - JSON schema for the form.
|
||||
@EVENTS:
|
||||
- submit: Object - Dispatched when the form is submitted, containing the form data.
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
export let schema;
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
/**
|
||||
* @purpose Dispatches the submit event with the form data.
|
||||
* @pre formData contains user input.
|
||||
* @post 'submit' event is dispatched with formData.
|
||||
*/
|
||||
function handleSubmit() {
|
||||
console.log("[DynamicForm][Action] Submitting form data.", { formData });
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
// [/DEF:handleSubmit:Function]
|
||||
|
||||
// [DEF:initializeForm:Function]
|
||||
/**
|
||||
* @purpose Initialize form data with default values from the schema.
|
||||
* @pre schema is provided and contains properties.
|
||||
* @post formData is initialized with default values or empty strings.
|
||||
*/
|
||||
function initializeForm() {
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/DEF:initializeForm:Function]
|
||||
|
||||
initializeForm();
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DynamicForm:Component] -->
|
||||
<!-- [DEF:DynamicForm:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: form, schema, dynamic, json-schema
|
||||
@PURPOSE: Generates a form dynamically based on a JSON schema.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
|
||||
|
||||
@PROPS:
|
||||
- schema: Object - JSON schema for the form.
|
||||
@EVENTS:
|
||||
- submit: Object - Dispatched when the form is submitted, containing the form data.
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
let {
|
||||
schema,
|
||||
} = $props();
|
||||
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
/**
|
||||
* @purpose Dispatches the submit event with the form data.
|
||||
* @pre formData contains user input.
|
||||
* @post 'submit' event is dispatched with formData.
|
||||
*/
|
||||
function handleSubmit() {
|
||||
console.log("[DynamicForm][Action] Submitting form data.", { formData });
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
// [/DEF:handleSubmit:Function]
|
||||
|
||||
// [DEF:initializeForm:Function]
|
||||
/**
|
||||
* @purpose Initialize form data with default values from the schema.
|
||||
* @pre schema is provided and contains properties.
|
||||
* @post formData is initialized with default values or empty strings.
|
||||
*/
|
||||
function initializeForm() {
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/DEF:initializeForm:Function]
|
||||
|
||||
initializeForm();
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DynamicForm:Component] -->
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let label: string = "Select Environment";
|
||||
export let selectedId: string = "";
|
||||
export let environments: Array<{id: string, name: string, url: string}> = [];
|
||||
let {
|
||||
label = "",
|
||||
selectedId = "",
|
||||
environments = [],
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -53,8 +56,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:EnvSelector:Component] -->
|
||||
@@ -14,10 +14,13 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let sourceDatabases: Array<{uuid: string, database_name: string, engine?: string}> = [];
|
||||
export let targetDatabases: Array<{uuid: string, database_name: string}> = [];
|
||||
export let mappings: Array<{source_db_uuid: string, target_db_uuid: string}> = [];
|
||||
export let suggestions: Array<{source_db_uuid: string, target_db_uuid: string, confidence: number}> = [];
|
||||
let {
|
||||
sourceDatabases = [],
|
||||
targetDatabases = [],
|
||||
mappings = [],
|
||||
suggestions = [],
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -100,8 +103,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MappingTable:Component] -->
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let show: boolean = false;
|
||||
export let sourceDbName: string = "";
|
||||
export let sourceDbUuid: string = "";
|
||||
export let targetDatabases: Array<{uuid: string, database_name: string}> = [];
|
||||
let {
|
||||
show = false,
|
||||
sourceDbName = "",
|
||||
sourceDbUuid = "",
|
||||
targetDatabases = [],
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
let selectedTargetUuid = "";
|
||||
@@ -111,8 +114,5 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Modal specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MissingMappingModal:Component] -->
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
{$t.nav.dashboard}
|
||||
</a>
|
||||
<a
|
||||
href="/tasks"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
href="/reports"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/reports') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
{$t.nav.tasks}
|
||||
{$t.nav.reports}
|
||||
</a>
|
||||
<div class="relative inline-block group">
|
||||
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
|
||||
|
||||
@@ -7,90 +7,134 @@
|
||||
@RELATION: EMITS -> resume, cancel
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let show = false;
|
||||
export let databases = []; // List of database names requiring passwords
|
||||
export let errorMessage = "";
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
let { show = false, databases = [], errorMessage = "" } = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let passwords = {};
|
||||
let submitting = false;
|
||||
|
||||
|
||||
let passwords = $state({});
|
||||
let submitting = $state(false);
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
// @PURPOSE: Validates and dispatches the passwords to resume the task.
|
||||
// @PRE: All database passwords must be entered.
|
||||
// @POST: 'resume' event is dispatched with passwords.
|
||||
function handleSubmit() {
|
||||
if (submitting) return;
|
||||
|
||||
|
||||
// Validate all passwords entered
|
||||
const missing = databases.filter(db => !passwords[db]);
|
||||
const missing = databases.filter((db) => !passwords[db]);
|
||||
if (missing.length > 0) {
|
||||
alert(`Please enter passwords for: ${missing.join(', ')}`);
|
||||
alert(`Please enter passwords for: ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
submitting = true;
|
||||
dispatch('resume', { passwords });
|
||||
dispatch("resume", { passwords });
|
||||
// Reset submitting state is handled by parent or on close
|
||||
}
|
||||
// [/DEF:handleSubmit:Function]
|
||||
|
||||
|
||||
// [DEF:handleCancel:Function]
|
||||
// @PURPOSE: Cancels the password prompt.
|
||||
// @PRE: Modal is open.
|
||||
// @POST: 'cancel' event is dispatched and show is set to false.
|
||||
function handleCancel() {
|
||||
dispatch('cancel');
|
||||
dispatch("cancel");
|
||||
show = false;
|
||||
}
|
||||
// [/DEF:handleCancel:Function]
|
||||
|
||||
|
||||
// Reset passwords when modal opens/closes
|
||||
$: if (!show) {
|
||||
passwords = {};
|
||||
submitting = false;
|
||||
}
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
passwords = {};
|
||||
submitting = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={handleCancel}></div>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
onclick={handleCancel}
|
||||
></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true">​</span
|
||||
>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div
|
||||
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
|
||||
>
|
||||
<!-- Heroicon name: outline/lock-closed -->
|
||||
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
<div
|
||||
class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"
|
||||
>
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900"
|
||||
id="modal-title"
|
||||
>
|
||||
Database Password Required
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
The migration process requires passwords for the following databases to proceed.
|
||||
The migration process requires passwords for
|
||||
the following databases to proceed.
|
||||
</p>
|
||||
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="mb-4 p-2 bg-red-50 text-red-700 text-xs rounded border border-red-200">
|
||||
<div
|
||||
class="mb-4 p-2 bg-red-50 text-red-700 text-xs rounded border border-red-200"
|
||||
>
|
||||
Error: {errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#each databases as dbName}
|
||||
<div>
|
||||
<label for="password-{dbName}" class="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
for="password-{dbName}"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password for {dbName}
|
||||
</label>
|
||||
<input
|
||||
@@ -108,19 +152,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
on:click={handleSubmit}
|
||||
onclick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Resuming...' : 'Resume Migration'}
|
||||
{submitting ? "Resuming..." : "Resume Migration"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
on:click={handleCancel}
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
@@ -130,4 +176,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- [/DEF:PasswordPrompt:Component] -->
|
||||
<!-- [/DEF:PasswordPrompt:Component] -->
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { t } from '../lib/i18n';
|
||||
|
||||
export let tasks: Array<any> = [];
|
||||
export let loading: boolean = false;
|
||||
let {
|
||||
tasks = [],
|
||||
loading = false,
|
||||
selectedTaskId = null,
|
||||
} = $props();
|
||||
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -51,8 +55,8 @@
|
||||
// @PURPOSE: Dispatches a select event when a task is clicked.
|
||||
// @PRE: taskId is provided.
|
||||
// @POST: 'select' event is dispatched with task ID.
|
||||
function handleTaskClick(taskId: string) {
|
||||
dispatch('select', { id: taskId });
|
||||
function handleTaskClick(task: any) {
|
||||
dispatch('select', { id: task.id, task });
|
||||
}
|
||||
// [/DEF:handleTaskClick:Function]
|
||||
</script>
|
||||
@@ -67,8 +71,8 @@
|
||||
{#each tasks as task (task.id)}
|
||||
<li>
|
||||
<button
|
||||
class="block hover:bg-gray-50 w-full text-left transition duration-150 ease-in-out focus:outline-none"
|
||||
on:click={() => handleTaskClick(task.id)}
|
||||
class="block w-full text-left transition duration-150 ease-in-out focus:outline-none hover:bg-gray-50 {selectedTaskId === task.id ? 'bg-blue-50' : ''}"
|
||||
on:click={() => handleTaskClick(task)}
|
||||
>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -107,4 +111,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
|
||||
@@ -23,60 +23,47 @@
|
||||
import { t } from "../lib/i18n";
|
||||
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
|
||||
|
||||
export let show = false;
|
||||
export let inline = false;
|
||||
export let taskId = null;
|
||||
export let taskStatus = null;
|
||||
export let realTimeLogs = [];
|
||||
let {
|
||||
show = $bindable(false),
|
||||
inline = false,
|
||||
taskId = null,
|
||||
taskStatus = null,
|
||||
realTimeLogs = [],
|
||||
} = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let logs = [];
|
||||
let loading = false;
|
||||
let error = "";
|
||||
let logs = $state([]);
|
||||
let loading = $state(false);
|
||||
let error = $state("");
|
||||
let interval;
|
||||
let autoScroll = true;
|
||||
let autoScroll = $state(true);
|
||||
|
||||
$: shouldShow = inline || show;
|
||||
let shouldShow = $derived(inline || show);
|
||||
|
||||
// [DEF:handleRealTimeLogs:Action]
|
||||
/** @PURPOSE Append real-time logs as they arrive from WebSocket, preventing duplicates */
|
||||
$: if (realTimeLogs && realTimeLogs.length > 0) {
|
||||
const lastLog = realTimeLogs[realTimeLogs.length - 1];
|
||||
const exists = logs.some(
|
||||
(l) =>
|
||||
l.timestamp === lastLog.timestamp &&
|
||||
l.message === lastLog.message,
|
||||
);
|
||||
if (!exists) {
|
||||
logs = [...logs, lastLog];
|
||||
console.log(
|
||||
`[TaskLogViewer][Action] Appended real-time log, total=${logs.length}`,
|
||||
$effect(() => {
|
||||
if (realTimeLogs && realTimeLogs.length > 0) {
|
||||
const lastLog = realTimeLogs[realTimeLogs.length - 1];
|
||||
const exists = logs.some(
|
||||
(l) =>
|
||||
l.timestamp === lastLog.timestamp &&
|
||||
l.message === lastLog.message,
|
||||
);
|
||||
if (!exists) {
|
||||
logs = [...logs, lastLog];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// [/DEF:handleRealTimeLogs:Action]
|
||||
|
||||
// [DEF:fetchLogs:Function]
|
||||
/**
|
||||
* @PURPOSE Fetches logs for the current task from API (polling fallback).
|
||||
* @PRE taskId must be set.
|
||||
* @POST logs array is updated with data from taskService.
|
||||
* @SIDE_EFFECT Updates logs, loading, and error state.
|
||||
*/
|
||||
async function fetchLogs() {
|
||||
if (!taskId) return;
|
||||
console.log(`[TaskLogViewer][Action] Fetching logs for task=${taskId}`);
|
||||
try {
|
||||
logs = await getTaskLogs(taskId);
|
||||
console.log(
|
||||
`[TaskLogViewer][Coherence:OK] Logs fetched count=${logs.length}`,
|
||||
);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
console.error(
|
||||
`[TaskLogViewer][Coherence:Failed] Error: ${e.message}`,
|
||||
);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -85,34 +72,31 @@
|
||||
|
||||
function handleFilterChange(event) {
|
||||
const { source, level } = event.detail;
|
||||
console.log(
|
||||
`[TaskLogViewer][Action] Filter changed: source=${source}, level=${level}`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
console.log(`[TaskLogViewer][Action] Manual refresh`);
|
||||
fetchLogs();
|
||||
}
|
||||
|
||||
// React to changes in show/taskId/taskStatus
|
||||
$: if (shouldShow && taskId) {
|
||||
if (interval) clearInterval(interval);
|
||||
logs = [];
|
||||
loading = true;
|
||||
error = "";
|
||||
fetchLogs();
|
||||
$effect(() => {
|
||||
if (shouldShow && taskId) {
|
||||
if (interval) clearInterval(interval);
|
||||
logs = [];
|
||||
loading = true;
|
||||
error = "";
|
||||
fetchLogs();
|
||||
|
||||
if (
|
||||
taskStatus === "RUNNING" ||
|
||||
taskStatus === "AWAITING_INPUT" ||
|
||||
taskStatus === "AWAITING_MAPPING"
|
||||
) {
|
||||
interval = setInterval(fetchLogs, 5000);
|
||||
if (
|
||||
taskStatus === "RUNNING" ||
|
||||
taskStatus === "AWAITING_INPUT" ||
|
||||
taskStatus === "AWAITING_MAPPING"
|
||||
) {
|
||||
interval = setInterval(fetchLogs, 5000);
|
||||
}
|
||||
} else {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
} else {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
@@ -121,18 +105,25 @@
|
||||
|
||||
{#if shouldShow}
|
||||
{#if inline}
|
||||
<div class="log-viewer-inline">
|
||||
<div class="flex flex-col h-full w-full">
|
||||
{#if loading && logs.length === 0}
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 h-full text-terminal-text-subtle text-sm"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-terminal-border border-t-primary rounded-full animate-spin"
|
||||
></div>
|
||||
<span>{$t.tasks?.loading || "Loading logs..."}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<span class="error-icon">⚠</span>
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 h-full text-log-error text-sm"
|
||||
>
|
||||
<span class="text-xl">⚠</span>
|
||||
<span>{error}</span>
|
||||
<button class="retry-btn" on:click={handleRefresh}
|
||||
>Retry</button
|
||||
<button
|
||||
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded-md px-3 py-1 text-xs cursor-pointer transition-all hover:bg-terminal-border hover:text-terminal-text-bright"
|
||||
onclick={handleRefresh}>Retry</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -156,13 +147,13 @@
|
||||
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
class="fixed inset-0 bg-gray-500/75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
show = false;
|
||||
dispatch("close");
|
||||
}}
|
||||
on:keydown={(e) => e.key === "Escape" && (show = false)}
|
||||
onkeydown={(e) => e.key === "Escape" && (show = false)}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
@@ -179,7 +170,7 @@
|
||||
</h3>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-300"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
show = false;
|
||||
dispatch("close");
|
||||
}}
|
||||
@@ -210,67 +201,3 @@
|
||||
{/if}
|
||||
|
||||
<!-- [/DEF:TaskLogViewer:Component] -->
|
||||
|
||||
<style>
|
||||
.log-viewer-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
color: #f87171;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background-color: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background-color: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
* @type {Backup[]}
|
||||
* @description Array of backup objects to display.
|
||||
*/
|
||||
export let backups: Backup[] = [];
|
||||
let {
|
||||
backups = [],
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
</script>
|
||||
@@ -78,7 +81,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:BackupList:Component] -->
|
||||
@@ -19,8 +19,11 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
export let currentBranch = 'main';
|
||||
let {
|
||||
dashboardId,
|
||||
currentBranch = 'main',
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
let {
|
||||
dashboardId,
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
|
||||
@@ -12,22 +12,22 @@
|
||||
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { gitService } from '../../services/gitService';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { api } from '../../lib/api';
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { gitService } from "../../services/gitService";
|
||||
import { addToast as toast } from "../../lib/toasts.js";
|
||||
import { api } from "../../lib/api";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
export let show = false;
|
||||
let { dashboardId, show = false } = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let message = '';
|
||||
let message = "";
|
||||
let committing = false;
|
||||
let status = null;
|
||||
let diff = '';
|
||||
let diff = "";
|
||||
let loading = false;
|
||||
let generatingMessage = false;
|
||||
// [/SECTION]
|
||||
@@ -41,14 +41,18 @@
|
||||
async function handleGenerateMessage() {
|
||||
generatingMessage = true;
|
||||
try {
|
||||
console.log(`[CommitModal][Action] Generating commit message for dashboard ${dashboardId}`);
|
||||
console.log(
|
||||
`[CommitModal][Action] Generating commit message for dashboard ${dashboardId}`,
|
||||
);
|
||||
// postApi returns the JSON data directly or throws an error
|
||||
const data = await api.postApi(`/git/repositories/${dashboardId}/generate-message`);
|
||||
const data = await api.postApi(
|
||||
`/git/repositories/${dashboardId}/generate-message`,
|
||||
);
|
||||
message = data.message;
|
||||
toast('Commit message generated', 'success');
|
||||
toast("Commit message generated", "success");
|
||||
} catch (e) {
|
||||
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
|
||||
toast(e.message || 'Failed to generate message', 'error');
|
||||
toast(e.message || "Failed to generate message", "error");
|
||||
} finally {
|
||||
generatingMessage = false;
|
||||
}
|
||||
@@ -64,20 +68,32 @@
|
||||
if (!dashboardId || !show) return;
|
||||
loading = true;
|
||||
try {
|
||||
console.log(`[CommitModal][Action] Loading status and diff for ${dashboardId}`);
|
||||
console.log(
|
||||
`[CommitModal][Action] Loading status and diff for ${dashboardId}`,
|
||||
);
|
||||
status = await gitService.getStatus(dashboardId);
|
||||
// Fetch both unstaged and staged diffs to show complete picture
|
||||
const unstagedDiff = await gitService.getDiff(dashboardId, null, false);
|
||||
const stagedDiff = await gitService.getDiff(dashboardId, null, true);
|
||||
|
||||
const unstagedDiff = await gitService.getDiff(
|
||||
dashboardId,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const stagedDiff = await gitService.getDiff(
|
||||
dashboardId,
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
diff = "";
|
||||
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
|
||||
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
|
||||
|
||||
if (stagedDiff)
|
||||
diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
|
||||
if (unstagedDiff)
|
||||
diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
|
||||
|
||||
if (!diff) diff = "";
|
||||
} catch (e) {
|
||||
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
|
||||
toast('Failed to load changes', 'error');
|
||||
toast("Failed to load changes", "error");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -92,39 +108,50 @@
|
||||
*/
|
||||
async function handleCommit() {
|
||||
if (!message) return;
|
||||
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`);
|
||||
console.log(
|
||||
`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`,
|
||||
);
|
||||
committing = true;
|
||||
try {
|
||||
await gitService.commit(dashboardId, message, []);
|
||||
toast('Changes committed successfully', 'success');
|
||||
dispatch('commit');
|
||||
toast("Changes committed successfully", "success");
|
||||
dispatch("commit");
|
||||
show = false;
|
||||
message = '';
|
||||
message = "";
|
||||
console.log(`[CommitModal][Coherence:OK] Committed`);
|
||||
} catch (e) {
|
||||
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
|
||||
toast(e.message, 'error');
|
||||
toast(e.message, "error");
|
||||
} finally {
|
||||
committing = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:handleCommit:Function]
|
||||
|
||||
$: if (show) loadStatus();
|
||||
$effect(() => {
|
||||
if (show) loadStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
{#if show}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
|
||||
>
|
||||
<h2 class="text-xl font-bold mb-4">Commit Changes</h2>
|
||||
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden">
|
||||
<!-- Left: Message and Files -->
|
||||
<div class="w-full md:w-1/3 flex flex-col">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Commit Message</label>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Commit Message</label
|
||||
>
|
||||
<button
|
||||
on:click={handleGenerateMessage}
|
||||
disabled={generatingMessage || loading}
|
||||
@@ -146,21 +173,37 @@
|
||||
|
||||
{#if status}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<h3 class="text-sm font-bold text-gray-500 uppercase mb-2">Changed Files</h3>
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-500 uppercase mb-2"
|
||||
>
|
||||
Changed Files
|
||||
</h3>
|
||||
<ul class="text-xs space-y-1">
|
||||
{#each status.staged_files as file}
|
||||
<li class="text-green-600 flex items-center font-semibold" title="Staged">
|
||||
<span class="mr-2">S</span> {file}
|
||||
<li
|
||||
class="text-green-600 flex items-center font-semibold"
|
||||
title="Staged"
|
||||
>
|
||||
<span class="mr-2">S</span>
|
||||
{file}
|
||||
</li>
|
||||
{/each}
|
||||
{#each status.modified_files as file}
|
||||
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)">
|
||||
<span class="mr-2">M</span> {file}
|
||||
<li
|
||||
class="text-yellow-600 flex items-center"
|
||||
title="Modified (Unstaged)"
|
||||
>
|
||||
<span class="mr-2">M</span>
|
||||
{file}
|
||||
</li>
|
||||
{/each}
|
||||
{#each status.untracked_files as file}
|
||||
<li class="text-blue-600 flex items-center" title="Untracked">
|
||||
<span class="mr-2">?</span> {file}
|
||||
<li
|
||||
class="text-blue-600 flex items-center"
|
||||
title="Untracked"
|
||||
>
|
||||
<span class="mr-2">?</span>
|
||||
{file}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -169,33 +212,52 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Diff Viewer -->
|
||||
<div class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50">
|
||||
<div class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b">Changes Preview</div>
|
||||
<div
|
||||
class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b"
|
||||
>
|
||||
Changes Preview
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-2">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-full text-gray-500">Loading diff...</div>
|
||||
<div
|
||||
class="flex items-center justify-center h-full text-gray-500"
|
||||
>
|
||||
Loading diff...
|
||||
</div>
|
||||
{:else if diff}
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
|
||||
<pre
|
||||
class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-500 italic">No changes detected</div>
|
||||
<div
|
||||
class="flex items-center justify-center h-full text-gray-500 italic"
|
||||
>
|
||||
No changes detected
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
on:click={handleCommit}
|
||||
disabled={committing || !message || loading || (!status?.is_dirty && status?.staged_files?.length === 0)}
|
||||
disabled={committing ||
|
||||
!message ||
|
||||
loading ||
|
||||
(!status?.is_dirty &&
|
||||
status?.staged_files?.length === 0)}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{committing ? 'Committing...' : 'Commit'}
|
||||
{committing ? "Committing..." : "Commit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,10 +265,4 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
pre {
|
||||
tab-size: 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:CommitModal:Component] -->
|
||||
<!-- [/DEF:CommitModal:Component] -->
|
||||
|
||||
@@ -10,20 +10,23 @@
|
||||
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { addToast as toast } from "../../lib/toasts.js";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
|
||||
export let conflicts = [];
|
||||
export let show = false;
|
||||
let {
|
||||
conflicts = [],
|
||||
show = false,
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
const dispatch = createEventDispatcher();
|
||||
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
|
||||
let resolutions = {};
|
||||
let resolutions = {};
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:resolve:Function]
|
||||
@@ -36,7 +39,9 @@
|
||||
* @side_effect Updates resolutions state.
|
||||
*/
|
||||
function resolve(file, strategy) {
|
||||
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`);
|
||||
console.log(
|
||||
`[ConflictResolver][Action] Resolving ${file} with ${strategy}`,
|
||||
);
|
||||
resolutions[file] = strategy;
|
||||
resolutions = { ...resolutions }; // Trigger update
|
||||
}
|
||||
@@ -51,16 +56,21 @@
|
||||
*/
|
||||
function handleSave() {
|
||||
// 1. Guard Clause (@PRE)
|
||||
const unresolved = conflicts.filter(c => !resolutions[c.file_path]);
|
||||
const unresolved = conflicts.filter((c) => !resolutions[c.file_path]);
|
||||
if (unresolved.length > 0) {
|
||||
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`);
|
||||
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error');
|
||||
console.warn(
|
||||
`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`,
|
||||
);
|
||||
toast(
|
||||
`Please resolve all conflicts first. (${unresolved.length} remaining)`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Implementation
|
||||
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
|
||||
dispatch('resolve', resolutions);
|
||||
dispatch("resolve", resolutions);
|
||||
show = false;
|
||||
}
|
||||
// [/DEF:handleSave:Function]
|
||||
@@ -68,43 +78,78 @@
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
{#if show}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<h2 class="text-xl font-bold mb-4 text-red-600">Merge Conflicts Detected</h2>
|
||||
<p class="text-gray-600 mb-4">The following files have conflicts. Please choose how to resolve them.</p>
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"
|
||||
>
|
||||
<h2 class="text-xl font-bold mb-4 text-red-600">
|
||||
Merge Conflicts Detected
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
The following files have conflicts. Please choose how to resolve
|
||||
them.
|
||||
</p>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2">
|
||||
{#each conflicts as conflict}
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center">
|
||||
<div
|
||||
class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center"
|
||||
>
|
||||
<span>{conflict.file_path}</span>
|
||||
{#if resolutions[conflict.file_path]}
|
||||
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold">
|
||||
<span
|
||||
class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold"
|
||||
>
|
||||
Resolved: {resolutions[conflict.file_path]}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x">
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x"
|
||||
>
|
||||
<div class="p-0 flex flex-col">
|
||||
<div class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b">Your Changes (Mine)</div>
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
|
||||
<div
|
||||
class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b"
|
||||
>
|
||||
Your Changes (Mine)
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'mine' ? 'bg-blue-600 text-white' : 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
|
||||
on:click={() => resolve(conflict.file_path, 'mine')}
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre
|
||||
class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[
|
||||
conflict.file_path
|
||||
] === 'mine'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
|
||||
on:click={() =>
|
||||
resolve(conflict.file_path, "mine")}
|
||||
>
|
||||
Keep Mine
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-0 flex flex-col">
|
||||
<div class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b">Remote Changes (Theirs)</div>
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
|
||||
<div
|
||||
class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b"
|
||||
>
|
||||
Remote Changes (Theirs)
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'theirs' ? 'bg-green-600 text-white' : 'bg-gray-50 hover:bg-green-50 text-green-600'}"
|
||||
on:click={() => resolve(conflict.file_path, 'theirs')}
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre
|
||||
class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[
|
||||
conflict.file_path
|
||||
] === 'theirs'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-green-50 text-green-600'}"
|
||||
on:click={() =>
|
||||
resolve(conflict.file_path, "theirs")}
|
||||
>
|
||||
Keep Theirs
|
||||
</button>
|
||||
@@ -115,13 +160,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
on:click={handleSave}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
@@ -133,10 +178,4 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
pre {
|
||||
tab-size: 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:ConflictResolver:Component] -->
|
||||
<!-- [/DEF:ConflictResolver:Component] -->
|
||||
|
||||
@@ -11,19 +11,19 @@
|
||||
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { gitService } from '../../services/gitService';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
import { gitService } from "../../services/gitService";
|
||||
import { addToast as toast } from "../../lib/toasts.js";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
export let show = false;
|
||||
let { dashboardId, show = false } = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let environments = [];
|
||||
let selectedEnv = '';
|
||||
let selectedEnv = "";
|
||||
let loading = false;
|
||||
let deploying = false;
|
||||
// [/SECTION]
|
||||
@@ -31,7 +31,9 @@
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:loadStatus:Watcher]
|
||||
$: if (show) loadEnvironments();
|
||||
$effect(() => {
|
||||
if (show) loadEnvironments();
|
||||
});
|
||||
// [/DEF:loadStatus:Watcher]
|
||||
|
||||
// [DEF:loadEnvironments:Function]
|
||||
@@ -48,10 +50,12 @@
|
||||
if (environments.length > 0) {
|
||||
selectedEnv = environments[0].id;
|
||||
}
|
||||
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`);
|
||||
console.log(
|
||||
`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
|
||||
toast('Failed to load environments', 'error');
|
||||
toast("Failed to load environments", "error");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -71,13 +75,16 @@
|
||||
deploying = true;
|
||||
try {
|
||||
const result = await gitService.deploy(dashboardId, selectedEnv);
|
||||
toast(result.message || 'Deployment triggered successfully', 'success');
|
||||
dispatch('deploy');
|
||||
toast(
|
||||
result.message || "Deployment triggered successfully",
|
||||
"success",
|
||||
);
|
||||
dispatch("deploy");
|
||||
show = false;
|
||||
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
|
||||
} catch (e) {
|
||||
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
|
||||
toast(e.message, 'error');
|
||||
toast(e.message, "error");
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
@@ -87,17 +94,21 @@
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
{#if show}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-96">
|
||||
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
|
||||
|
||||
|
||||
{#if loading}
|
||||
<p class="text-gray-500">Loading environments...</p>
|
||||
{:else if environments.length === 0}
|
||||
<p class="text-red-500 mb-4">No deployment environments configured.</p>
|
||||
<p class="text-red-500 mb-4">
|
||||
No deployment environments configured.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
@@ -105,33 +116,53 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label>
|
||||
<select
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Select Target Environment</label
|
||||
>
|
||||
<select
|
||||
bind:value={selectedEnv}
|
||||
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
|
||||
>
|
||||
{#each environments as env}
|
||||
<option value={env.id}>{env.name} ({env.superset_url})</option>
|
||||
<option value={env.id}
|
||||
>{env.name} ({env.superset_url})</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
on:click={handleDeploy}
|
||||
disabled={deploying || !selectedEnv}
|
||||
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{#if deploying}
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Deploying...
|
||||
{:else}
|
||||
@@ -145,4 +176,4 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DeploymentModal:Component] -->
|
||||
<!-- [/DEF:DeploymentModal:Component] -->
|
||||
|
||||
@@ -26,9 +26,12 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
export let dashboardTitle = "";
|
||||
export let show = false;
|
||||
let {
|
||||
dashboardId,
|
||||
dashboardTitle = "",
|
||||
show = false,
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
import { t } from '../../lib/i18n';
|
||||
|
||||
/** @type {Object} */
|
||||
export let documentation = null;
|
||||
export let onSave = async (doc) => {};
|
||||
export let onCancel = () => {};
|
||||
let {
|
||||
content = "",
|
||||
type = 'markdown',
|
||||
format = 'text',
|
||||
} = $props();
|
||||
|
||||
|
||||
let isSaving = false;
|
||||
|
||||
|
||||
@@ -7,64 +7,74 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '../../lib/i18n';
|
||||
import { requestApi } from '../../lib/api';
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { requestApi } from "../../lib/api";
|
||||
|
||||
/** @type {Array} */
|
||||
export let providers = [];
|
||||
export let onSave = () => {};
|
||||
let { providers = [], onSave = () => {} } = $props();
|
||||
|
||||
let editingProvider = null;
|
||||
let showForm = false;
|
||||
|
||||
let formData = {
|
||||
name: '',
|
||||
provider_type: 'openai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
default_model: 'gpt-4o',
|
||||
is_active: true
|
||||
name: "",
|
||||
provider_type: "openai",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
default_model: "gpt-4o",
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
let testStatus = { type: '', message: '' };
|
||||
let testStatus = { type: "", message: "" };
|
||||
let isTesting = false;
|
||||
|
||||
function resetForm() {
|
||||
formData = {
|
||||
name: '',
|
||||
provider_type: 'openai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
default_model: 'gpt-4o',
|
||||
is_active: true
|
||||
name: "",
|
||||
provider_type: "openai",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
default_model: "gpt-4o",
|
||||
is_active: true,
|
||||
};
|
||||
editingProvider = null;
|
||||
testStatus = { type: '', message: '' };
|
||||
testStatus = { type: "", message: "" };
|
||||
}
|
||||
|
||||
function handleEdit(provider) {
|
||||
editingProvider = provider;
|
||||
formData = { ...provider, api_key: '' }; // Don't populate key for security
|
||||
formData = { ...provider, api_key: "" }; // Don't populate key for security
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
console.log("[ProviderConfig][Action] Testing connection", formData);
|
||||
isTesting = true;
|
||||
testStatus = { type: 'info', message: $t.llm.testing };
|
||||
|
||||
testStatus = { type: "info", message: $t.llm.testing };
|
||||
|
||||
try {
|
||||
const endpoint = editingProvider ? `/llm/providers/${editingProvider.id}/test` : '/llm/providers/test';
|
||||
const result = await requestApi(endpoint, 'POST', formData);
|
||||
|
||||
const endpoint = editingProvider
|
||||
? `/llm/providers/${editingProvider.id}/test`
|
||||
: "/llm/providers/test";
|
||||
const result = await requestApi(endpoint, "POST", formData);
|
||||
|
||||
if (result.success) {
|
||||
testStatus = { type: 'success', message: $t.llm.connection_success };
|
||||
testStatus = { type: "success", message: $t.llm.connection_success };
|
||||
} else {
|
||||
testStatus = { type: 'error', message: $t.llm.connection_failed.replace('{error}', result.error || 'Unknown error') };
|
||||
testStatus = {
|
||||
type: "error",
|
||||
message: $t.llm.connection_failed.replace(
|
||||
"{error}",
|
||||
result.error || "Unknown error",
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
testStatus = { type: 'error', message: $t.llm.connection_failed.replace('{error}', err.message) };
|
||||
testStatus = {
|
||||
type: "error",
|
||||
message: $t.llm.connection_failed.replace("{error}", err.message),
|
||||
};
|
||||
} finally {
|
||||
isTesting = false;
|
||||
}
|
||||
@@ -72,8 +82,10 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
console.log("[ProviderConfig][Action] Submitting provider config");
|
||||
const method = editingProvider ? 'PUT' : 'POST';
|
||||
const endpoint = editingProvider ? `/llm/providers/${editingProvider.id}` : '/llm/providers';
|
||||
const method = editingProvider ? "PUT" : "POST";
|
||||
const endpoint = editingProvider
|
||||
? `/llm/providers/${editingProvider.id}`
|
||||
: "/llm/providers";
|
||||
|
||||
// When editing, only include api_key if user entered a new one
|
||||
const submitData = { ...formData };
|
||||
@@ -94,9 +106,9 @@
|
||||
|
||||
async function toggleActive(provider) {
|
||||
try {
|
||||
await requestApi(`/llm/providers/${provider.id}`, 'PUT', {
|
||||
await requestApi(`/llm/providers/${provider.id}`, "PUT", {
|
||||
...provider,
|
||||
is_active: !provider.is_active
|
||||
is_active: !provider.is_active,
|
||||
});
|
||||
onSave();
|
||||
} catch (err) {
|
||||
@@ -108,28 +120,53 @@
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold">{$t.llm.providers_title}</h2>
|
||||
<button
|
||||
<button
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
|
||||
on:click={() => { resetForm(); showForm = true; }}
|
||||
on:click={() => {
|
||||
resetForm();
|
||||
showForm = true;
|
||||
}}
|
||||
>
|
||||
{$t.llm.add_provider}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">{editingProvider ? $t.llm.edit_provider : $t.llm.new_provider}</h3>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
{editingProvider ? $t.llm.edit_provider : $t.llm.new_provider}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="provider-name" class="block text-sm font-medium text-gray-700">{$t.llm.name}</label>
|
||||
<input id="provider-name" type="text" bind:value={formData.name} class="mt-1 block w-full border rounded-md p-2" placeholder="e.g. My OpenAI" />
|
||||
<label
|
||||
for="provider-name"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>{$t.llm.name}</label
|
||||
>
|
||||
<input
|
||||
id="provider-name"
|
||||
type="text"
|
||||
bind:value={formData.name}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
placeholder="e.g. My OpenAI"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="provider-type" class="block text-sm font-medium text-gray-700">{$t.llm.type}</label>
|
||||
<select id="provider-type" bind:value={formData.provider_type} class="mt-1 block w-full border rounded-md p-2">
|
||||
<label
|
||||
for="provider-type"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>{$t.llm.type}</label
|
||||
>
|
||||
<select
|
||||
id="provider-type"
|
||||
bind:value={formData.provider_type}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="kilo">Kilo</option>
|
||||
@@ -137,47 +174,88 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="provider-base-url" class="block text-sm font-medium text-gray-700">{$t.llm.base_url}</label>
|
||||
<input id="provider-base-url" type="text" bind:value={formData.base_url} class="mt-1 block w-full border rounded-md p-2" />
|
||||
<label
|
||||
for="provider-base-url"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>{$t.llm.base_url}</label
|
||||
>
|
||||
<input
|
||||
id="provider-base-url"
|
||||
type="text"
|
||||
bind:value={formData.base_url}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="provider-api-key" class="block text-sm font-medium text-gray-700">{$t.llm.api_key}</label>
|
||||
<input id="provider-api-key" type="password" bind:value={formData.api_key} class="mt-1 block w-full border rounded-md p-2" placeholder={editingProvider ? "••••••••" : "sk-..."} />
|
||||
<label
|
||||
for="provider-api-key"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>{$t.llm.api_key}</label
|
||||
>
|
||||
<input
|
||||
id="provider-api-key"
|
||||
type="password"
|
||||
bind:value={formData.api_key}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
placeholder={editingProvider ? "••••••••" : "sk-..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="provider-default-model" class="block text-sm font-medium text-gray-700">{$t.llm.default_model}</label>
|
||||
<input id="provider-default-model" type="text" bind:value={formData.default_model} class="mt-1 block w-full border rounded-md p-2" placeholder="gpt-4o" />
|
||||
<label
|
||||
for="provider-default-model"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>{$t.llm.default_model}</label
|
||||
>
|
||||
<input
|
||||
id="provider-default-model"
|
||||
type="text"
|
||||
bind:value={formData.default_model}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input id="provider-active" type="checkbox" bind:checked={formData.is_active} class="mr-2" />
|
||||
<label for="provider-active" class="text-sm font-medium text-gray-700">{$t.llm.active}</label>
|
||||
<input
|
||||
id="provider-active"
|
||||
type="checkbox"
|
||||
bind:checked={formData.is_active}
|
||||
class="mr-2"
|
||||
/>
|
||||
<label
|
||||
for="provider-active"
|
||||
class="text-sm font-medium text-gray-700">{$t.llm.active}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if testStatus.message}
|
||||
<div class={`mt-4 p-2 rounded text-sm ${testStatus.type === 'success' ? 'bg-green-100 text-green-800' : testStatus.type === 'error' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'}`}>
|
||||
<div
|
||||
class={`mt-4 p-2 rounded text-sm ${testStatus.type === "success" ? "bg-green-100 text-green-800" : testStatus.type === "error" ? "bg-red-100 text-red-800" : "bg-blue-100 text-blue-800"}`}
|
||||
>
|
||||
{testStatus.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between gap-2">
|
||||
<button
|
||||
<button
|
||||
class="px-4 py-2 border rounded hover:bg-gray-50 flex-1"
|
||||
on:click={() => { showForm = false; }}
|
||||
on:click={() => {
|
||||
showForm = false;
|
||||
}}
|
||||
>
|
||||
{$t.llm.cancel}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 flex-1"
|
||||
disabled={isTesting}
|
||||
on:click={testConnection}
|
||||
>
|
||||
{isTesting ? $t.llm.testing : $t.llm.test}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex-1"
|
||||
on:click={handleSubmit}
|
||||
>
|
||||
@@ -190,37 +268,45 @@
|
||||
|
||||
<div class="grid gap-4">
|
||||
{#each providers as provider}
|
||||
<div class="border rounded-lg p-4 flex justify-between items-center bg-white shadow-sm">
|
||||
<div
|
||||
class="border rounded-lg p-4 flex justify-between items-center bg-white shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<div class="font-bold flex items-center gap-2">
|
||||
{provider.name}
|
||||
<span class={`text-xs px-2 py-0.5 rounded-full ${provider.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{provider.is_active ? $t.llm.active : 'Inactive'}
|
||||
<span
|
||||
class={`text-xs px-2 py-0.5 rounded-full ${provider.is_active ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}`}
|
||||
>
|
||||
{provider.is_active ? $t.llm.active : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">{provider.provider_type} • {provider.default_model}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{provider.provider_type} • {provider.default_model}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<button
|
||||
class="text-sm text-blue-600 hover:underline"
|
||||
on:click={() => handleEdit(provider)}
|
||||
>
|
||||
{$t.common.edit}
|
||||
</button>
|
||||
<button
|
||||
class={`text-sm ${provider.is_active ? 'text-orange-600' : 'text-green-600'} hover:underline`}
|
||||
<button
|
||||
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
|
||||
on:click={() => toggleActive(provider)}
|
||||
>
|
||||
{provider.is_active ? 'Deactivate' : 'Activate'}
|
||||
{provider.is_active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
|
||||
<div
|
||||
class="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg"
|
||||
>
|
||||
{$t.llm.no_providers}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ProviderConfig:Component] -->
|
||||
<!-- [/DEF:ProviderConfig:Component] -->
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<!-- @PURPOSE: Displays the results of an LLM-based dashboard validation task. -->
|
||||
|
||||
<script>
|
||||
export let result = null;
|
||||
let {
|
||||
report,
|
||||
} = $props();
|
||||
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
import { t } from '../../lib/i18n';
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
export let files = [];
|
||||
let {
|
||||
files = [],
|
||||
} = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:isDirectory:Function]
|
||||
@@ -137,8 +140,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* ... */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:FileList:Component] -->
|
||||
@@ -26,8 +26,11 @@
|
||||
*/
|
||||
const dispatch = createEventDispatcher();
|
||||
let fileInput;
|
||||
export let category = 'backups';
|
||||
export let path = '';
|
||||
let {
|
||||
category = 'backups',
|
||||
path = '',
|
||||
} = $props();
|
||||
|
||||
let isUploading = false;
|
||||
let dragOver = false;
|
||||
|
||||
@@ -128,8 +131,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* ... */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:FileUpload:Component] -->
|
||||
@@ -8,14 +8,10 @@
|
||||
-->
|
||||
<script>
|
||||
/** @type {Object} log - The log entry object */
|
||||
export let log;
|
||||
/** @type {boolean} showSource - Whether to show the source tag */
|
||||
export let showSource = true;
|
||||
let { log, showSource = true } = $props();
|
||||
|
||||
// [DEF:formatTime:Function]
|
||||
/** @PURPOSE Format ISO timestamp to HH:MM:SS */
|
||||
$: formattedTime = formatTime(log.timestamp);
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return "";
|
||||
const date = new Date(timestamp);
|
||||
@@ -28,180 +24,77 @@
|
||||
}
|
||||
// [/DEF:formatTime:Function]
|
||||
|
||||
$: levelClass = getLevelClass(log.level);
|
||||
let formattedTime = $derived(formatTime(log.timestamp));
|
||||
|
||||
function getLevelClass(level) {
|
||||
switch (level?.toUpperCase()) {
|
||||
case "DEBUG":
|
||||
return "level-debug";
|
||||
case "INFO":
|
||||
return "level-info";
|
||||
case "WARNING":
|
||||
return "level-warning";
|
||||
case "ERROR":
|
||||
return "level-error";
|
||||
default:
|
||||
return "level-info";
|
||||
}
|
||||
}
|
||||
const levelStyles = {
|
||||
DEBUG: "text-log-debug bg-log-debug/15",
|
||||
INFO: "text-log-info bg-log-info/10",
|
||||
WARNING: "text-log-warning bg-log-warning/10",
|
||||
ERROR: "text-log-error bg-log-error/10",
|
||||
};
|
||||
|
||||
$: sourceClass = getSourceClass(log.source);
|
||||
const sourceStyles = {
|
||||
plugin: "bg-source-plugin/10 text-source-plugin",
|
||||
"superset-api": "bg-source-api/10 text-source-api",
|
||||
superset_api: "bg-source-api/10 text-source-api",
|
||||
git: "bg-source-git/10 text-source-git",
|
||||
system: "bg-source-system/10 text-source-system",
|
||||
};
|
||||
|
||||
function getSourceClass(source) {
|
||||
if (!source) return "source-default";
|
||||
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
|
||||
}
|
||||
let levelClass = $derived(
|
||||
levelStyles[log.level?.toUpperCase()] || levelStyles.INFO,
|
||||
);
|
||||
let sourceClass = $derived(
|
||||
sourceStyles[log.source?.toLowerCase()] ||
|
||||
"bg-log-debug/15 text-terminal-text-subtle",
|
||||
);
|
||||
|
||||
$: hasProgress = log.metadata?.progress !== undefined;
|
||||
$: progressPercent = log.metadata?.progress || 0;
|
||||
let hasProgress = $derived(log.metadata?.progress !== undefined);
|
||||
let progressPercent = $derived(log.metadata?.progress || 0);
|
||||
</script>
|
||||
|
||||
<div class="log-row">
|
||||
<div
|
||||
class="py-2 px-3 border-b border-terminal-surface/60 transition-colors hover:bg-terminal-surface/50"
|
||||
>
|
||||
<!-- Meta line: time + level + source -->
|
||||
<div class="log-meta">
|
||||
<span class="log-time">{formattedTime}</span>
|
||||
<span class="log-level {levelClass}">{log.level || "INFO"}</span>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono text-[0.6875rem] text-terminal-text-muted shrink-0"
|
||||
>{formattedTime}</span
|
||||
>
|
||||
<span
|
||||
class="font-mono font-semibold uppercase text-[0.625rem] px-1.5 py-px rounded-sm tracking-wider shrink-0 {levelClass}"
|
||||
>{log.level || "INFO"}</span
|
||||
>
|
||||
{#if showSource && log.source}
|
||||
<span class="log-source {sourceClass}">{log.source}</span>
|
||||
<span
|
||||
class="text-[0.625rem] px-1.5 py-px rounded-sm shrink-0 {sourceClass}"
|
||||
>{log.source}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="log-message">{log.message}</div>
|
||||
<div
|
||||
class="font-mono text-[0.8125rem] leading-relaxed text-terminal-text break-words whitespace-pre-wrap"
|
||||
>
|
||||
{log.message}
|
||||
</div>
|
||||
|
||||
<!-- Progress bar (if applicable) -->
|
||||
{#if hasProgress}
|
||||
<div class="progress-container">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<div
|
||||
class="flex-1 h-1.5 bg-terminal-surface rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-primary to-purple-500 rounded-full transition-[width] duration-300 ease-out"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-text">{progressPercent.toFixed(0)}%</span>
|
||||
<span class="font-mono text-[0.625rem] text-terminal-text-subtle shrink-0"
|
||||
>{progressPercent.toFixed(0)}%</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/DEF:LogEntryRow:Component] -->
|
||||
|
||||
<style>
|
||||
.log-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(30, 41, 59, 0.6);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background-color: rgba(30, 41, 59, 0.5);
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.1875rem;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
color: #64748b;
|
||||
background-color: rgba(100, 116, 139, 0.15);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
color: #38bdf8;
|
||||
background-color: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: #fbbf24;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.level-error {
|
||||
color: #f87171;
|
||||
background-color: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.log-source {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.1875rem;
|
||||
background-color: rgba(100, 116, 139, 0.15);
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-plugin {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.source-superset-api,
|
||||
.source-superset_api {
|
||||
background-color: rgba(168, 85, 247, 0.1);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.source-git {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.source-system {
|
||||
background-color: rgba(56, 189, 248, 0.1);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 0.375rem;
|
||||
background-color: #1e293b;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.625rem;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
/** @type {string[]} availableSources - List of available source options */
|
||||
export let availableSources = [];
|
||||
/** @type {string} selectedLevel - Currently selected log level filter */
|
||||
export let selectedLevel = "";
|
||||
/** @type {string} selectedSource - Currently selected source filter */
|
||||
export let selectedSource = "";
|
||||
/** @type {string} searchText - Current search text */
|
||||
export let searchText = "";
|
||||
let {
|
||||
availableSources = [],
|
||||
selectedLevel = $bindable(""),
|
||||
selectedSource = $bindable(""),
|
||||
searchText = $bindable(""),
|
||||
} = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -63,15 +61,20 @@
|
||||
dispatch("filter-change", { level: "", source: "", search: "" });
|
||||
}
|
||||
|
||||
$: hasActiveFilters = selectedLevel || selectedSource || searchText;
|
||||
let hasActiveFilters = $derived(
|
||||
selectedLevel || selectedSource || searchText,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="filter-controls">
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-2 bg-terminal-bg border-b border-terminal-surface"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<select
|
||||
class="filter-select"
|
||||
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
|
||||
style="background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.375rem center;"
|
||||
value={selectedLevel}
|
||||
on:change={handleLevelChange}
|
||||
onchange={handleLevelChange}
|
||||
aria-label="Filter by level"
|
||||
>
|
||||
{#each levelOptions as option}
|
||||
@@ -80,9 +83,10 @@
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="filter-select"
|
||||
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
|
||||
style="background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.375rem center;"
|
||||
value={selectedSource}
|
||||
on:change={handleSourceChange}
|
||||
onchange={handleSourceChange}
|
||||
aria-label="Filter by source"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
@@ -91,9 +95,9 @@
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="search-wrapper">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<svg
|
||||
class="search-icon"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 text-terminal-text-muted pointer-events-none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -107,10 +111,10 @@
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
class="w-full bg-terminal-surface text-terminal-text-bright border border-terminal-border rounded py-[0.3125rem] px-2 pl-7 text-xs placeholder:text-terminal-text-muted focus:outline-none focus:border-primary-ring"
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
on:input={handleSearchChange}
|
||||
oninput={handleSearchChange}
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
</div>
|
||||
@@ -118,8 +122,8 @@
|
||||
|
||||
{#if hasActiveFilters}
|
||||
<button
|
||||
class="clear-btn"
|
||||
on:click={clearFilters}
|
||||
class="flex items-center justify-center p-[0.3125rem] bg-transparent border border-terminal-border rounded text-terminal-text-subtle shrink-0 cursor-pointer transition-all hover:text-log-error hover:border-log-error hover:bg-log-error/10"
|
||||
onclick={clearFilters}
|
||||
aria-label="Clear filters"
|
||||
>
|
||||
<svg
|
||||
@@ -137,98 +141,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/DEF:LogFilterBar:Component] -->
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #0f172a;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background-color: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.375rem center;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #475569;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.3125rem 0.5rem 0.3125rem 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3125rem;
|
||||
background: none;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.25rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: #f87171;
|
||||
border-color: #f87171;
|
||||
background-color: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,25 +12,21 @@
|
||||
@UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher, onMount, afterUpdate } from "svelte";
|
||||
import { createEventDispatcher, onMount, tick } from "svelte";
|
||||
import LogFilterBar from "./LogFilterBar.svelte";
|
||||
import LogEntryRow from "./LogEntryRow.svelte";
|
||||
|
||||
/**
|
||||
* @PURPOSE Component properties and state.
|
||||
* @PRE logs is an array of LogEntry objects.
|
||||
*/
|
||||
export let logs = [];
|
||||
export let autoScroll = true;
|
||||
let { logs = [], autoScroll = $bindable(true) } = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let scrollContainer;
|
||||
let selectedSource = "all";
|
||||
let selectedLevel = "all";
|
||||
let searchText = "";
|
||||
let selectedSource = $state("all");
|
||||
let selectedLevel = $state("all");
|
||||
let searchText = $state("");
|
||||
|
||||
// Filtered logs based on current filters
|
||||
$: filteredLogs = filterLogs(logs, selectedLevel, selectedSource, searchText);
|
||||
let filteredLogs = $derived(
|
||||
filterLogs(logs, selectedLevel, selectedSource, searchText),
|
||||
);
|
||||
|
||||
function filterLogs(allLogs, level, source, search) {
|
||||
return allLogs.filter((log) => {
|
||||
@@ -52,17 +48,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Extract unique sources from logs
|
||||
$: availableSources = [...new Set(logs.map((l) => l.source).filter(Boolean))];
|
||||
let availableSources = $derived([
|
||||
...new Set(logs.map((l) => l.source).filter(Boolean)),
|
||||
]);
|
||||
|
||||
function handleFilterChange(event) {
|
||||
const { source, level, search } = event.detail;
|
||||
selectedSource = source || "all";
|
||||
selectedLevel = level || "all";
|
||||
searchText = search || "";
|
||||
console.log(
|
||||
`[TaskLogPanel][Action] Filter: level=${selectedLevel}, source=${selectedSource}, search=${searchText}`,
|
||||
);
|
||||
dispatch("filterChange", { source, level });
|
||||
}
|
||||
|
||||
@@ -77,8 +71,11 @@
|
||||
if (autoScroll) scrollToBottom();
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
scrollToBottom();
|
||||
// Use $effect instead of afterUpdate for runes mode
|
||||
$effect(() => {
|
||||
// Track filteredLogs length to trigger scroll
|
||||
filteredLogs.length;
|
||||
tick().then(scrollToBottom);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
@@ -86,14 +83,19 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="flex flex-col h-full bg-terminal-bg overflow-hidden">
|
||||
<!-- Filter Bar -->
|
||||
<LogFilterBar {availableSources} on:filter-change={handleFilterChange} />
|
||||
|
||||
<!-- Log List -->
|
||||
<div bind:this={scrollContainer} class="log-list">
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{#if filteredLogs.length === 0}
|
||||
<div class="empty-logs">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 px-4 text-terminal-border gap-3"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
@@ -109,7 +111,9 @@
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span>No logs available</span>
|
||||
<span class="text-[0.8125rem] text-terminal-text-muted"
|
||||
>No logs available</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredLogs as log}
|
||||
@@ -119,129 +123,27 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="log-footer">
|
||||
<span class="log-count">
|
||||
<div
|
||||
class="flex items-center justify-between py-1.5 px-3 border-t border-terminal-surface bg-terminal-bg"
|
||||
>
|
||||
<span class="font-mono text-[0.6875rem] text-terminal-text-muted">
|
||||
{filteredLogs.length}{filteredLogs.length !== logs.length
|
||||
? ` / ${logs.length}`
|
||||
: ""} entries
|
||||
</span>
|
||||
<button
|
||||
class="autoscroll-btn"
|
||||
class:active={autoScroll}
|
||||
on:click={toggleAutoScroll}
|
||||
class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle
|
||||
{autoScroll ? 'text-terminal-accent' : ''}"
|
||||
onclick={toggleAutoScroll}
|
||||
aria-label="Toggle auto-scroll"
|
||||
>
|
||||
{#if autoScroll}
|
||||
<span class="pulse-dot"></span>
|
||||
<span
|
||||
class="inline-block w-[5px] h-[5px] rounded-full bg-terminal-accent animate-pulse"
|
||||
></span>
|
||||
{/if}
|
||||
Auto-scroll {autoScroll ? "on" : "off"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/DEF:TaskLogPanel:Component] -->
|
||||
|
||||
<style>
|
||||
.log-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.log-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.log-list::-webkit-scrollbar-track {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.log-list::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.log-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #334155;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-logs span {
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.autoscroll-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.autoscroll-btn:hover {
|
||||
background-color: #1e293b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.autoscroll-btn.active {
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: #22d3ee;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
114
frontend/src/components/tasks/TaskResultPanel.svelte
Normal file
114
frontend/src/components/tasks/TaskResultPanel.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
let { task = null } = $props();
|
||||
|
||||
const result = $derived(task?.result || null);
|
||||
const pluginId = $derived(task?.plugin_id || '');
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'PASS':
|
||||
case 'SUCCESS':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'WARN':
|
||||
case 'PARTIAL_SUCCESS':
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
case 'FAIL':
|
||||
case 'FAILED':
|
||||
return 'bg-red-100 text-red-700';
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !task}
|
||||
<div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 p-6 text-sm text-slate-500">
|
||||
Выберите задачу, чтобы увидеть результат.
|
||||
</div>
|
||||
{:else if !result}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<p class="text-sm text-slate-700">Для этой задачи нет структурированного результата.</p>
|
||||
</div>
|
||||
{:else if pluginId === 'llm_dashboard_validation'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">LLM проверка дашборда</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-700">{result.summary || 'Нет summary'}</p>
|
||||
{#if result.issues?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Проблемы ({result.issues.length})</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.issues as issue}
|
||||
<li class="rounded-md border border-slate-200 bg-slate-50 p-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`rounded px-2 py-0.5 text-xs font-semibold ${statusColor(issue.severity)}`}>{issue.severity}</span>
|
||||
<span class="text-slate-700">{issue.message}</span>
|
||||
</div>
|
||||
{#if issue.location}
|
||||
<p class="mt-1 text-xs text-slate-500">Локация: {issue.location}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if pluginId === 'superset-backup'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Результат бэкапа</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
|
||||
<p>Environment: {result.environment || '-'}</p>
|
||||
<p>Total: {result.total_dashboards ?? 0}</p>
|
||||
<p>Успешно: {result.backed_up_dashboards ?? 0}</p>
|
||||
<p>Ошибок: {result.failed_dashboards ?? 0}</p>
|
||||
</div>
|
||||
{#if result.failures?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.failures as failure}
|
||||
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{failure.title || failure.id}: {failure.error}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if pluginId === 'superset-migration'}
|
||||
<div class="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Результат миграции</h3>
|
||||
<span class={`rounded-full px-2 py-1 text-xs font-semibold ${statusColor(result.status)}`}>{result.status || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-slate-700">
|
||||
<p>Source: {result.source_environment || '-'}</p>
|
||||
<p>Target: {result.target_environment || '-'}</p>
|
||||
<p>Выбрано: {result.selected_dashboards ?? 0}</p>
|
||||
<p>Успешно: {result.migrated_dashboards?.length ?? 0}</p>
|
||||
<p>С ошибками: {result.failed_dashboards?.length ?? 0}</p>
|
||||
<p>Mappings: {result.mapping_count ?? 0}</p>
|
||||
</div>
|
||||
{#if result.failed_dashboards?.length}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Ошибки миграции</p>
|
||||
<ul class="space-y-2">
|
||||
{#each result.failed_dashboards as failed}
|
||||
<li class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{failed.title || failed.id}: {failed.error}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<pre class="overflow-auto rounded bg-slate-50 p-3 text-xs text-slate-700">{JSON.stringify(result, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,10 +1,17 @@
|
||||
<!-- [DEF:Counter:Component] -->
|
||||
<!--
|
||||
@TIER: TRIVIAL
|
||||
@PURPOSE: Simple counter demo component
|
||||
@LAYER: UI
|
||||
-->
|
||||
<script>
|
||||
let count = $state(0)
|
||||
let count = $state(0);
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
count += 1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>
|
||||
count is {count}
|
||||
</button>
|
||||
<!-- [/DEF:Counter:Component] -->
|
||||
|
||||
@@ -149,7 +149,19 @@ export const api = {
|
||||
postApi,
|
||||
requestApi,
|
||||
getPlugins: () => fetchApi('/plugins'),
|
||||
getTasks: () => fetchApi('/tasks'),
|
||||
getTasks: (options = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit != null) params.append('limit', String(options.limit));
|
||||
if (options.offset != null) params.append('offset', String(options.offset));
|
||||
if (options.status) params.append('status', options.status);
|
||||
if (options.task_type) params.append('task_type', options.task_type);
|
||||
if (options.completed_only != null) params.append('completed_only', String(Boolean(options.completed_only)));
|
||||
if (Array.isArray(options.plugin_id)) {
|
||||
options.plugin_id.forEach((pluginId) => params.append('plugin_id', pluginId));
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchApi(`/tasks${query ? `?${query}` : ''}`);
|
||||
},
|
||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||
createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
|
||||
|
||||
|
||||
83
frontend/src/lib/api/reports.js
Normal file
83
frontend/src/lib/api/reports.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// [DEF:frontend.src.lib.api.reports:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: frontend, api_client, reports, wrapper
|
||||
// @PURPOSE: Wrapper-based reports API client for list/detail retrieval without direct native fetch usage.
|
||||
// @LAYER: Infra
|
||||
// @RELATION: DEPENDS_ON -> [DEF:api_module]
|
||||
// @INVARIANT: Uses existing api wrapper methods and returns structured errors for UI-state mapping.
|
||||
|
||||
import { api } from '../api.js';
|
||||
|
||||
// [DEF:buildReportQueryString:Function]
|
||||
// @PURPOSE: Build query string for reports list endpoint from filter options.
|
||||
// @PRE: options is an object with optional report query fields.
|
||||
// @POST: Returns URL query string without leading '?'.
|
||||
export function buildReportQueryString(options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.page != null) params.append('page', String(options.page));
|
||||
if (options.page_size != null) params.append('page_size', String(options.page_size));
|
||||
|
||||
if (Array.isArray(options.task_types) && options.task_types.length > 0) {
|
||||
params.append('task_types', options.task_types.join(','));
|
||||
}
|
||||
if (Array.isArray(options.statuses) && options.statuses.length > 0) {
|
||||
params.append('statuses', options.statuses.join(','));
|
||||
}
|
||||
|
||||
if (options.time_from) params.append('time_from', options.time_from);
|
||||
if (options.time_to) params.append('time_to', options.time_to);
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.sort_by) params.append('sort_by', options.sort_by);
|
||||
if (options.sort_order) params.append('sort_order', options.sort_order);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
// [/DEF:buildReportQueryString:Function]
|
||||
|
||||
// [DEF:normalizeApiError:Function]
|
||||
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
|
||||
// @PRE: error may be Error/string/object.
|
||||
// @POST: Returns structured error object.
|
||||
export function normalizeApiError(error) {
|
||||
const message =
|
||||
(error && typeof error.message === 'string' && error.message) ||
|
||||
(typeof error === 'string' && error) ||
|
||||
'Failed to load reports';
|
||||
|
||||
return {
|
||||
message,
|
||||
code: 'REPORTS_API_ERROR',
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
// [/DEF:normalizeApiError:Function]
|
||||
|
||||
// [DEF:getReports:Function]
|
||||
// @PURPOSE: Fetch unified report list using existing request wrapper.
|
||||
// @PRE: valid auth context for protected endpoint.
|
||||
// @POST: Returns parsed payload or structured error for UI-state mapping.
|
||||
export async function getReports(options = {}) {
|
||||
try {
|
||||
const query = buildReportQueryString(options);
|
||||
return await api.fetchApi(`/reports${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReports:Function]
|
||||
|
||||
// [DEF:getReportDetail:Function]
|
||||
// @PURPOSE: Fetch one report detail by report_id.
|
||||
// @PRE: reportId is non-empty string; valid auth context.
|
||||
// @POST: Returns parsed detail payload or structured error object.
|
||||
export async function getReportDetail(reportId) {
|
||||
try {
|
||||
return await api.fetchApi(`/reports/${reportId}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReportDetail:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.api.reports:Module]
|
||||
@@ -6,19 +6,21 @@
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> page store
|
||||
* @INVARIANT: Always shows current page path
|
||||
*
|
||||
*
|
||||
* @UX_STATE: Idle -> Breadcrumbs showing current path
|
||||
* @UX_FEEDBACK: Hover on breadcrumb shows clickable state
|
||||
* @UX_RECOVERY: Click breadcrumb to navigate
|
||||
*/
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { t, _ } from '$lib/i18n';
|
||||
import { page } from "$app/stores";
|
||||
import { t, _ } from "$lib/i18n";
|
||||
|
||||
export let maxVisible = 3;
|
||||
let { maxVisible = 3 } = $props();
|
||||
|
||||
// Breadcrumb items derived from current path
|
||||
$: breadcrumbItems = getBreadcrumbs($page?.url?.pathname || '/', maxVisible);
|
||||
let breadcrumbItems = $derived(
|
||||
getBreadcrumbs($page?.url?.pathname || "/", maxVisible),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate breadcrumb items from path
|
||||
@@ -26,47 +28,31 @@
|
||||
* @returns {Array} Array of breadcrumb items
|
||||
*/
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const allItems = [
|
||||
{ label: 'Home', path: '/' }
|
||||
];
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const allItems = [{ label: "Home", path: "/" }];
|
||||
|
||||
let currentPath = '';
|
||||
let currentPath = "";
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
// Convert segment to readable label
|
||||
const label = formatBreadcrumbLabel(segment);
|
||||
allItems.push({
|
||||
label,
|
||||
path: currentPath,
|
||||
isLast: index === segments.length - 1
|
||||
isLast: index === segments.length - 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle truncation if too many items
|
||||
// If we have more than maxVisible items, we truncate the middle ones
|
||||
// Always show Home (first) and Current (last)
|
||||
if (allItems.length > maxVisible) {
|
||||
const firstItem = allItems[0];
|
||||
const lastItem = allItems[allItems.length - 1];
|
||||
|
||||
// Calculate how many items we can show in the middle
|
||||
// We reserve 1 for first, 1 for last, and 1 for ellipsis
|
||||
// But ellipsis isn't a real item in terms of logic, it just replaces hidden ones
|
||||
// Actually, let's keep it simple: First ... [Last - (maxVisible - 2) .. Last]
|
||||
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
// Add the last (maxVisible - 2) items
|
||||
// e.g. if maxVisible is 3, we show Start ... End
|
||||
// if maxVisible is 4, we show Start ... SecondLast End
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for(let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
const firstItem = allItems[0];
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for (let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
@@ -78,63 +64,46 @@
|
||||
* @returns {string} Formatted label
|
||||
*/
|
||||
function formatBreadcrumbLabel(segment) {
|
||||
// Handle special cases
|
||||
const specialCases = {
|
||||
'dashboards': 'nav.dashboard',
|
||||
'datasets': 'nav.tools_mapper',
|
||||
'storage': 'nav.tools_storage',
|
||||
'admin': 'nav.admin',
|
||||
'settings': 'nav.settings',
|
||||
'git': 'nav.git'
|
||||
};
|
||||
dashboards: "nav.dashboard",
|
||||
datasets: "nav.tools_mapper",
|
||||
storage: "nav.tools_storage",
|
||||
admin: "nav.admin",
|
||||
settings: "nav.settings",
|
||||
git: "nav.git",
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return _(specialCases[segment]) || segment;
|
||||
}
|
||||
|
||||
// Default: capitalize and replace hyphens with spaces
|
||||
return segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
@apply flex items-center space-x-2 text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
@apply hover:text-blue-600 hover:underline cursor-pointer transition-colors;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
@apply text-gray-900 font-medium;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb navigation">
|
||||
<nav
|
||||
class="flex items-center space-x-2 text-sm text-gray-600"
|
||||
aria-label="Breadcrumb navigation"
|
||||
>
|
||||
{#each breadcrumbItems as item, index}
|
||||
<div class="breadcrumb-item">
|
||||
<div class="flex items-center">
|
||||
{#if item.isEllipsis}
|
||||
<span class="breadcrumb-separator">...</span>
|
||||
<span class="text-gray-400">...</span>
|
||||
{:else if item.isLast}
|
||||
<span class="breadcrumb-current">{item.label}</span>
|
||||
<span class="text-gray-900 font-medium">{item.label}</span>
|
||||
{:else}
|
||||
<a href={item.path} class="breadcrumb-link">{item.label}</a>
|
||||
<a
|
||||
href={item.path}
|
||||
class="hover:text-primary hover:underline cursor-pointer transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if index < breadcrumbItems.length - 1}
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="text-gray-400">/</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
@@ -39,7 +39,7 @@
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
@@ -48,7 +48,7 @@
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
@@ -58,10 +58,17 @@
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports || "REPORTS",
|
||||
icon: "M4 5h16M4 12h16M4 19h10",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
@@ -75,7 +82,7 @@
|
||||
let activeCategory = "dashboards";
|
||||
let activeItem = "/dashboards";
|
||||
let isMobileOpen = false;
|
||||
let expandedCategories = new Set(["dashboards"]); // Track expanded categories
|
||||
let expandedCategories = new Set(["dashboards"]);
|
||||
|
||||
// Subscribe to sidebar store
|
||||
$: if ($sidebarStore) {
|
||||
@@ -90,7 +97,7 @@
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
@@ -99,7 +106,7 @@
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
@@ -108,7 +115,7 @@
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
@@ -118,10 +125,17 @@
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports || "REPORTS",
|
||||
icon: "M4 5h16M4 12h16M4 19h10",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
@@ -133,7 +147,6 @@
|
||||
|
||||
// Update active item when page changes
|
||||
$: if ($page && $page.url.pathname !== activeItem) {
|
||||
// Find matching category
|
||||
const matched = categories.find((cat) =>
|
||||
$page.url.pathname.startsWith(cat.path),
|
||||
);
|
||||
@@ -143,7 +156,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on sidebar item
|
||||
function handleItemClick(category) {
|
||||
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
|
||||
setActiveItem(category.id, category.path);
|
||||
@@ -153,7 +165,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on category header to toggle expansion
|
||||
function handleCategoryToggle(categoryId, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -173,28 +184,24 @@
|
||||
} else {
|
||||
expandedCategories.add(categoryId);
|
||||
}
|
||||
expandedCategories = expandedCategories; // Trigger reactivity
|
||||
expandedCategories = expandedCategories;
|
||||
}
|
||||
|
||||
// Handle click on sub-item
|
||||
function handleSubItemClick(categoryId, path) {
|
||||
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
|
||||
setActiveItem(categoryId, path);
|
||||
closeMobile();
|
||||
// Force navigation if it's a link
|
||||
if (browser) {
|
||||
window.location.href = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toggle button click
|
||||
function handleToggleClick(event) {
|
||||
event.stopPropagation();
|
||||
console.log("[Sidebar][Action] Toggle sidebar");
|
||||
toggleSidebar();
|
||||
}
|
||||
|
||||
// Handle mobile overlay click
|
||||
function handleOverlayClick() {
|
||||
console.log("[Sidebar][Action] Close mobile overlay");
|
||||
closeMobile();
|
||||
@@ -209,7 +216,7 @@
|
||||
<!-- Mobile overlay (only on mobile) -->
|
||||
{#if isMobileOpen}
|
||||
<div
|
||||
class="mobile-overlay"
|
||||
class="fixed inset-0 bg-black/50 z-20 md:hidden"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
|
||||
role="presentation"
|
||||
@@ -218,12 +225,18 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
class="sidebar {isExpanded ? 'expanded' : 'collapsed'} {isMobileOpen
|
||||
? 'mobile'
|
||||
: 'mobile-hidden'}"
|
||||
class="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 transition-[width] duration-200 ease-in-out
|
||||
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
|
||||
{isMobileOpen
|
||||
? 'translate-x-0 w-sidebar'
|
||||
: '-translate-x-full md:translate-x-0'}"
|
||||
>
|
||||
<!-- Header (simplified, toggle moved to footer) -->
|
||||
<div class="sidebar-header {isExpanded ? '' : 'collapsed'}">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center p-4 border-b border-gray-200 {isExpanded
|
||||
? 'justify-between'
|
||||
: 'justify-center'}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<span class="font-semibold text-gray-800">Menu</span>
|
||||
{:else}
|
||||
@@ -232,13 +245,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Navigation items -->
|
||||
<nav class="nav-section">
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
{#each categories as category}
|
||||
<div class="category">
|
||||
<div>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="category-header {activeCategory === category.id
|
||||
? 'active'
|
||||
class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
|
||||
: ''}"
|
||||
on:click={(e) => handleCategoryToggle(category.id, e)}
|
||||
on:keydown={(e) =>
|
||||
@@ -251,7 +265,7 @@
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="nav-icon"
|
||||
class="w-5 h-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -261,13 +275,17 @@
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
{#if isExpanded}
|
||||
<span class="nav-label">{category.label}</span>
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<svg
|
||||
class="category-toggle {expandedCategories.has(category.id)
|
||||
? 'expanded'
|
||||
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -284,10 +302,13 @@
|
||||
|
||||
<!-- Sub Items (only when expanded) -->
|
||||
{#if isExpanded && expandedCategories.has(category.id)}
|
||||
<div class="sub-items">
|
||||
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
|
||||
{#each category.subItems as subItem}
|
||||
<div
|
||||
class="sub-item {activeItem === subItem.path ? 'active' : ''}"
|
||||
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
|
||||
{activeItem === subItem.path
|
||||
? 'bg-primary-light text-primary'
|
||||
: ''}"
|
||||
on:click={() => handleSubItemClick(category.id, subItem.path)}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
@@ -306,8 +327,11 @@
|
||||
|
||||
<!-- Footer with Collapse button -->
|
||||
{#if isExpanded}
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" on:click={handleToggleClick}>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -324,8 +348,12 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" on:click={handleToggleClick} aria-label="Expand sidebar">
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -337,101 +365,10 @@
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
<span class="collapse-btn-text">Expand</span>
|
||||
<span class="ml-2">Expand</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:Sidebar:Component] -->
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
@apply bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar.expanded {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar.mobile {
|
||||
@apply translate-x-0;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar.mobile-hidden {
|
||||
@apply -translate-x-full md:translate-x-0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@apply flex items-center justify-between p-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.sidebar-header.collapsed {
|
||||
@apply justify-center;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@apply w-5 h-5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@apply ml-3 text-sm font-medium truncate;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
@apply flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.category-header.active {
|
||||
@apply bg-blue-50 text-blue-600 md:border-r-2 md:border-blue-600;
|
||||
}
|
||||
|
||||
.category-toggle {
|
||||
@apply text-gray-400 transition-transform duration-200;
|
||||
}
|
||||
|
||||
.category-toggle.expanded {
|
||||
@apply rotate-180;
|
||||
}
|
||||
|
||||
.sub-items {
|
||||
@apply bg-gray-50 overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.sub-item {
|
||||
@apply flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900;
|
||||
}
|
||||
|
||||
.sub-item.active {
|
||||
@apply bg-blue-50 text-blue-600;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
@apply border-t border-gray-200 p-4;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
@apply flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.collapse-btn-text {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 z-20;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-overlay {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
function goToReportsPage() {
|
||||
closeDrawer();
|
||||
window.location.href = "/reports";
|
||||
}
|
||||
|
||||
// Handle overlay click
|
||||
function handleOverlayClick(event) {
|
||||
if (event.target === event.currentTarget) {
|
||||
@@ -185,33 +190,32 @@
|
||||
<!-- Drawer Overlay -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="drawer-overlay"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={(e) => e.key === "Escape" && handleClose()}
|
||||
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<!-- Drawer Panel -->
|
||||
<div
|
||||
class="drawer"
|
||||
class="fixed right-0 top-0 h-full w-full max-w-[560px] bg-slate-900 shadow-[-8px_0_30px_rgba(0,0,0,0.3)] flex flex-col z-50 transition-transform duration-300 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Task drawer"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="drawer-header">
|
||||
<div class="header-left">
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-slate-800 bg-slate-900">
|
||||
<div class="flex items-center gap-2.5">
|
||||
{#if !activeTaskId && recentTasks.length > 0}
|
||||
<!-- Показываем индикатор что это режим списка -->
|
||||
<span class="list-indicator">
|
||||
<span class="flex items-center justify-center p-1.5 mr-1 text-cyan-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</span>
|
||||
{:else if activeTaskId}
|
||||
<button
|
||||
class="back-btn"
|
||||
class="flex items-center justify-center p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={goBackToList}
|
||||
aria-label="Back to task list"
|
||||
>
|
||||
@@ -228,39 +232,47 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 class="drawer-title">
|
||||
{activeTaskId ? ($t.tasks?.details_logs || "Task Details & Logs") : "Recent Tasks"}
|
||||
<h2 class="text-sm font-semibold text-slate-100 tracking-tight">
|
||||
{activeTaskId ? ($t.tasks?.details_logs || 'Task Details & Logs') : 'Recent Tasks'}
|
||||
</h2>
|
||||
{#if shortTaskId}
|
||||
<span class="task-id-badge">{shortTaskId}…</span>
|
||||
<span class="text-xs font-mono text-slate-500 bg-slate-800 px-2 py-0.5 rounded">{shortTaskId}…</span>
|
||||
{/if}
|
||||
{#if taskStatus}
|
||||
<span class="status-badge {taskStatus.toLowerCase()}"
|
||||
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {taskStatus.toLowerCase() === 'running' ? 'text-cyan-400 bg-cyan-400/10 border border-cyan-400/20' : taskStatus.toLowerCase() === 'success' ? 'text-green-400 bg-green-400/10 border border-green-400/20' : 'text-red-400 bg-red-400/10 border border-red-400/20'}"
|
||||
>{taskStatus}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="close-btn"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-700 text-slate-300 bg-slate-800/60 hover:bg-slate-800 transition-colors"
|
||||
on:click={goToReportsPage}
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{$t.nav?.reports || "Reports"}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="drawer-content">
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if activeTaskId}
|
||||
<TaskLogViewer
|
||||
inline={true}
|
||||
@@ -269,31 +281,28 @@
|
||||
{realTimeLogs}
|
||||
/>
|
||||
{:else if loadingTasks}
|
||||
<!-- Loading State -->
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<div class="flex flex-col items-center justify-center p-12 text-slate-500">
|
||||
<div class="w-8 h-8 border-2 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
{:else if recentTasks.length > 0}
|
||||
<!-- Task List -->
|
||||
<div class="task-list">
|
||||
<h3 class="task-list-title">Recent Tasks</h3>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-100 mb-4 pb-2 border-b border-slate-800">Recent Tasks</h3>
|
||||
{#each recentTasks as task}
|
||||
<button
|
||||
class="task-item"
|
||||
class="flex items-center gap-3 w-full p-3 mb-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer transition-all hover:bg-slate-700 hover:border-slate-600 text-left"
|
||||
on:click={() => selectTask(task)}
|
||||
>
|
||||
<span class="task-item-id">{task.id?.substring(0, 8) || 'N/A'}...</span>
|
||||
<span class="task-item-plugin">{task.plugin_id || 'Unknown'}</span>
|
||||
<span class="task-item-status {task.status?.toLowerCase()}">{task.status || 'UNKNOWN'}</span>
|
||||
<span class="font-mono text-xs text-slate-500">{task.id?.substring(0, 8) || 'N/A'}...</span>
|
||||
<span class="flex-1 text-sm text-slate-100 font-medium">{task.plugin_id || 'Unknown'}</span>
|
||||
<span class="text-xs font-semibold uppercase px-2 py-1 rounded-full {task.status?.toLowerCase() === 'running' || task.status?.toLowerCase() === 'pending' ? 'bg-cyan-500/15 text-cyan-400' : task.status?.toLowerCase() === 'completed' || task.status?.toLowerCase() === 'success' ? 'bg-green-500/15 text-green-400' : task.status?.toLowerCase() === 'failed' || task.status?.toLowerCase() === 'error' ? 'bg-red-500/15 text-red-400' : 'bg-slate-500/15 text-slate-400'}">{task.status || 'UNKNOWN'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state">
|
||||
<div class="flex flex-col items-center justify-center h-full text-slate-500">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
class="w-12 h-12 mb-3 text-slate-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -304,16 +313,16 @@
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<p>{$t.tasks?.select_task || "No recent tasks"}</p>
|
||||
<p>{$t.tasks?.select_task || 'No recent tasks'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="drawer-footer">
|
||||
<div class="footer-pulse"></div>
|
||||
<p class="drawer-footer-text">
|
||||
{$t.tasks?.footer_text || "Task continues running in background"}
|
||||
<div class="flex items-center gap-2 justify-center px-4 py-2.5 border-t border-slate-800 bg-slate-900">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></div>
|
||||
<p class="text-xs text-slate-500">
|
||||
{$t.tasks?.footer_text || 'Task continues running in background'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,292 +331,3 @@
|
||||
|
||||
<!-- [/DEF:TaskDrawer:Component] -->
|
||||
|
||||
<style>
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background-color: #0f172a;
|
||||
box-shadow: -8px 0 30px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.task-id-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
color: #64748b;
|
||||
background-color: #1e293b;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
color: #22d3ee;
|
||||
background-color: rgba(34, 211, 238, 0.1);
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
color: #4ade80;
|
||||
background-color: rgba(74, 222, 128, 0.1);
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
.status-badge.failed,
|
||||
.status-badge.error {
|
||||
color: #f87171;
|
||||
background-color: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
color: #64748b;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #f1f5f9;
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
color: #64748b;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #f1f5f9;
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.list-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
margin-right: 0.25rem;
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.footer-pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #22d3ee;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-footer-text {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-list-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: #334155;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.task-item-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-item-plugin {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #f1f5f9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-item-status {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.task-item-status.running,
|
||||
.task-item-status.pending {
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.task-item-status.completed,
|
||||
.task-item-status.success {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.task-item-status.failed,
|
||||
.task-item-status.error {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.task-item-status.cancelled {
|
||||
background: rgba(100, 116, 139, 0.15);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 0.875rem;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,54 +31,38 @@
|
||||
let showUserMenu = false;
|
||||
let isSearchFocused = false;
|
||||
|
||||
// Subscribe to sidebar store for responsive layout
|
||||
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
||||
|
||||
// Subscribe to activity store
|
||||
$: activeCount = $activityStore?.activeCount || 0;
|
||||
$: recentTasks = $activityStore?.recentTasks || [];
|
||||
|
||||
// Get user from auth store
|
||||
$: user = $auth?.user || null;
|
||||
|
||||
// Toggle user menu
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
showUserMenu = !showUserMenu;
|
||||
console.log(`[TopNavbar][Action] Toggle user menu: ${showUserMenu}`);
|
||||
}
|
||||
|
||||
// Close user menu
|
||||
function closeUserMenu() {
|
||||
showUserMenu = false;
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
function handleLogout() {
|
||||
console.log("[TopNavbar][Action] Logout");
|
||||
auth.logout();
|
||||
closeUserMenu();
|
||||
// Navigate to login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
// Handle activity indicator click - open Task Drawer with most recent task
|
||||
function handleActivityClick() {
|
||||
console.log("[TopNavbar][Action] Activity indicator clicked");
|
||||
// Open drawer with the most recent running task, or list mode
|
||||
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
|
||||
if (runningTask) {
|
||||
openDrawerForTask(runningTask.taskId);
|
||||
} else if (recentTasks.length > 0) {
|
||||
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
|
||||
} else {
|
||||
// No tracked tasks — open in list mode to show recent tasks from API
|
||||
openDrawer();
|
||||
}
|
||||
dispatch("activityClick");
|
||||
}
|
||||
|
||||
// Handle search focus
|
||||
function handleSearchFocus() {
|
||||
isSearchFocused = true;
|
||||
}
|
||||
@@ -87,34 +71,31 @@
|
||||
isSearchFocused = false;
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
function handleDocumentClick(event) {
|
||||
if (!event.target.closest(".user-menu-container")) {
|
||||
closeUserMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for document clicks
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
}
|
||||
|
||||
// Handle hamburger menu click for mobile
|
||||
function handleHamburgerClick(event) {
|
||||
event.stopPropagation();
|
||||
console.log("[TopNavbar][Action] Toggle mobile sidebar");
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="navbar {isExpanded ? 'with-sidebar' : 'with-collapsed-sidebar'} mobile"
|
||||
class="bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40
|
||||
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
||||
>
|
||||
<!-- Left section: Hamburger (mobile) + Logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Hamburger Menu (mobile only) -->
|
||||
<button
|
||||
class="hamburger-btn"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
|
||||
on:click={handleHamburgerClick}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@@ -134,9 +115,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Logo/Brand -->
|
||||
<a href="/" class="logo-link">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center text-xl font-bold text-gray-800 hover:text-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="logo-icon"
|
||||
class="w-8 h-8 mr-2 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -148,10 +132,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Search placeholder (non-functional for now) -->
|
||||
<div class="search-container">
|
||||
<div class="flex-1 max-w-xl mx-4 hidden md:block">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input {isSearchFocused ? 'focused' : ''}"
|
||||
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
||||
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
|
||||
placeholder={$t.common.search || "Search..."}
|
||||
on:focus={handleSearchFocus}
|
||||
on:blur={handleSearchBlur}
|
||||
@@ -159,10 +144,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Nav Actions -->
|
||||
<div class="nav-actions">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Activity Indicator -->
|
||||
<div
|
||||
class="activity-indicator"
|
||||
class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
on:click={handleActivityClick}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
|
||||
@@ -171,7 +156,7 @@
|
||||
aria-label="Activity"
|
||||
>
|
||||
<svg
|
||||
class="activity-icon"
|
||||
class="w-6 h-6 text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -183,14 +168,17 @@
|
||||
/>
|
||||
</svg>
|
||||
{#if activeCount > 0}
|
||||
<span class="activity-badge">{activeCount}</span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||
>{activeCount}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="user-menu-container">
|
||||
<div class="user-menu-container relative">
|
||||
<div
|
||||
class="user-avatar"
|
||||
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
|
||||
on:click={toggleUserMenu}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
|
||||
@@ -208,13 +196,17 @@
|
||||
</div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div class="user-dropdown {showUserMenu ? '' : 'hidden'}">
|
||||
<div class="dropdown-item">
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 {showUserMenu
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<div class="px-4 py-2 text-sm text-gray-700">
|
||||
<strong>{user?.username || "User"}</strong>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
on:click={() => {
|
||||
window.location.href = "/settings";
|
||||
}}
|
||||
@@ -227,7 +219,7 @@
|
||||
{$t.nav?.settings || "Settings"}
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-item danger"
|
||||
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
|
||||
on:click={handleLogout}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleLogout()}
|
||||
@@ -242,96 +234,3 @@
|
||||
</nav>
|
||||
|
||||
<!-- [/DEF:TopNavbar:Component] -->
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
@apply bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40;
|
||||
}
|
||||
|
||||
.navbar.with-sidebar {
|
||||
@apply md:left-64;
|
||||
}
|
||||
|
||||
.navbar.with-collapsed-sidebar {
|
||||
@apply md:left-16;
|
||||
}
|
||||
|
||||
.navbar.mobile {
|
||||
@apply left-0;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
@apply flex items-center text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
@apply w-8 h-8 mr-2 text-blue-600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply flex-1 max-w-xl mx-4;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all;
|
||||
}
|
||||
|
||||
.search-input.focused {
|
||||
@apply bg-white border border-blue-500;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
@apply flex items-center space-x-4;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
@apply p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
@apply relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors;
|
||||
}
|
||||
|
||||
.activity-badge {
|
||||
@apply absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
@apply w-6 h-6 text-gray-600;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@apply w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
|
||||
}
|
||||
|
||||
.user-dropdown.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer;
|
||||
}
|
||||
|
||||
.dropdown-item.danger {
|
||||
@apply text-red-600 hover:bg-red-50;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
@apply border-t border-gray-200 my-1;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// [DEF:__tests__/test_breadcrumbs:Module]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Contract-focused unit tests for Breadcrumbs.svelte logic and UX annotations
|
||||
// @LAYER: UI
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/layout/Breadcrumbs.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/layout/Breadcrumbs.svelte'
|
||||
);
|
||||
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const allItems = [{ label: 'Home', path: '/' }];
|
||||
|
||||
let currentPath = '';
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const label = formatBreadcrumbLabel(segment);
|
||||
allItems.push({
|
||||
label,
|
||||
path: currentPath,
|
||||
isLast: index === segments.length - 1
|
||||
});
|
||||
});
|
||||
|
||||
if (allItems.length > maxVisible) {
|
||||
const firstItem = allItems[0];
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for (let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
function formatBreadcrumbLabel(segment) {
|
||||
const specialCases = {
|
||||
dashboards: 'Dashboards',
|
||||
datasets: 'Datasets',
|
||||
storage: 'Storage',
|
||||
admin: 'Admin',
|
||||
settings: 'Settings',
|
||||
git: 'Git'
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return specialCases[segment];
|
||||
}
|
||||
|
||||
return segment
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
describe('Breadcrumbs Component Contract & Logic', () => {
|
||||
it('contains required UX tags and semantic header for STANDARD module', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('@TIER: STANDARD');
|
||||
expect(source).toContain('@UX_STATE: Idle');
|
||||
expect(source).toContain('@UX_FEEDBACK');
|
||||
expect(source).toContain('@UX_RECOVERY');
|
||||
expect(source).toContain('@RELATION: DEPENDS_ON -> page store');
|
||||
});
|
||||
|
||||
it('returns Home for root path (Short-Path UX state)', () => {
|
||||
const result = getBreadcrumbs('/', 3);
|
||||
|
||||
expect(result).toEqual([{ label: 'Home', path: '/' }]);
|
||||
});
|
||||
|
||||
it('maps known segments to expected labels', () => {
|
||||
expect(formatBreadcrumbLabel('dashboards')).toBe('Dashboards');
|
||||
expect(formatBreadcrumbLabel('datasets')).toBe('Datasets');
|
||||
expect(formatBreadcrumbLabel('settings')).toBe('Settings');
|
||||
});
|
||||
|
||||
it('formats unknown kebab-case segment to title case', () => {
|
||||
expect(formatBreadcrumbLabel('data-quality-rules')).toBe('Data Quality Rules');
|
||||
});
|
||||
|
||||
it('truncates long paths with ellipsis and keeps tail segments', () => {
|
||||
const result = getBreadcrumbs('/dashboards/segment-a/segment-b/segment-c', 3);
|
||||
|
||||
expect(result[0]).toEqual({ label: 'Home', path: '/' });
|
||||
expect(result[1]).toEqual({ isEllipsis: true });
|
||||
const lastItem = result[result.length - 1];
|
||||
expect('label' in lastItem && lastItem.label).toBe('Segment C');
|
||||
expect(result.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:__tests__/test_breadcrumbs:Module]
|
||||
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- [DEF:ReportCard:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, card, type-profile, accessibility, fallback
|
||||
* @PURPOSE: Render one report with explicit textual type label and profile-driven visual variant.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Unknown task type always uses fallback profile.
|
||||
*
|
||||
* @UX_STATE: Ready -> Card displays summary/status/type.
|
||||
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getReportTypeProfile } from './reportTypeProfiles.js';
|
||||
|
||||
let { report, selected = false } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const profile = $derived(getReportTypeProfile(report?.task_type));
|
||||
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700';
|
||||
if (status === 'failed') return 'bg-red-100 text-red-700';
|
||||
if (status === 'in_progress') return 'bg-blue-100 text-blue-700';
|
||||
if (status === 'partial') return 'bg-amber-100 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
dispatch('select', { report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-lg border p-3 text-left transition hover:bg-slate-50 {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
|
||||
on:click={onSelect}
|
||||
aria-label={`Report ${report?.report_id || ''} type ${profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}`}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
|
||||
{profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}
|
||||
</span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
|
||||
{report?.status || ($t.reports?.not_provided || 'Not provided')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-800">{report?.summary || ($t.reports?.not_provided || 'Not provided')}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
|
||||
</button>
|
||||
|
||||
<!-- [/DEF:ReportCard:Component] -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user