From 0653a75ee7d17e082cd7e1d34f1bef6e82b13028 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 22 May 2026 09:21:24 +0300 Subject: [PATCH] fix(db): add ondelete cascade to all FK constraints, fix multimodal flag persistence - Add ondelete=CASCADE/SET NULL to all environment and translate FK constraints - Add is_multimodal to GET /api/settings/consolidated response - Fix toggleActive to not overwrite is_multimodal with false - New SearchableMultiSelect component for dashboard search with multi-select - Fix validation task page: task data unwrapping, date formatting, dashboard multi-select - Fix infinite effect loop on dashboards page via queueMicrotask guard - 3 new Alembic migrations (merge f0e9d8c7b6a5, translate b0c1d2e3f4a5) --- ...t_null_ondelete_for_task_records_env_fk.py | 50 ++++ ...d2e3f4a5_cascade_ondelete_translate_fks.py | 83 ++++++ ...e9d8c7b6a5_cascade_ondelete_all_env_fks.py | 53 ++++ backend/src/api/routes/settings.py | 1 + .../dataset_review_pkg/_session_models.py | 2 +- backend/src/models/mapping.py | 10 +- backend/src/models/task.py | 2 +- backend/src/models/translate.py | 36 +-- .../src/components/llm/ProviderConfig.svelte | 1 - frontend/src/lib/api.js | 10 +- .../ui/SearchableMultiSelect.svelte | 246 ++++++++++++++++++ frontend/src/routes/dashboards/+page.svelte | 65 ++++- .../src/routes/validation/[id]/+page.svelte | 63 ++--- 13 files changed, 549 insertions(+), 73 deletions(-) create mode 100644 backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py create mode 100644 backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py create mode 100644 backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py create mode 100644 frontend/src/lib/components/ui/SearchableMultiSelect.svelte diff --git a/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py new file mode 100644 index 00000000..cfdc55d5 --- /dev/null +++ b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py @@ -0,0 +1,50 @@ +"""set null ondelete for task_records environment FK + +Revision ID: a5b6c7d8e9f0 +Revises: c4a3a2f74bfe +Create Date: 2026-05-21 18:55:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a5b6c7d8e9f0' +down_revision: Union[str, Sequence[str], None] = 'c4a3a2f74bfe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.drop_constraint( + 'task_records_environment_id_fkey', + 'task_records', + type_='foreignkey', + ) + op.create_foreign_key( + 'task_records_environment_id_fkey', + 'task_records', + 'environments', + ['environment_id'], + ['id'], + ondelete='SET NULL', + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint( + 'task_records_environment_id_fkey', + 'task_records', + type_='foreignkey', + ) + op.create_foreign_key( + 'task_records_environment_id_fkey', + 'task_records', + 'environments', + ['environment_id'], + ['id'], + ) diff --git a/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py new file mode 100644 index 00000000..4f478b2d --- /dev/null +++ b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py @@ -0,0 +1,83 @@ +"""cascade ondelete for all translate FK references (translation_jobs/runs/batches chain) + +Revision ID: b0c1d2e3f4a5 +Revises: f0e9d8c7b6a5 +Create Date: 2026-05-21 19:05:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'b0c1d2e3f4a5' +down_revision: Union[str, Sequence[str], None] = 'f0e9d8c7b6a5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Each entry: (table, constraint_name, column, ref_table, ondelete_rule) +FK_FIXES = [ + # Direct FKs to translation_jobs.id + ('translation_runs', 'translation_runs_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_events', 'translation_events_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_preview_sessions', 'translation_preview_sessions_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_schedules', 'translation_schedules_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_job_dictionaries', 'translation_job_dictionaries_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_metric_snapshots', 'translation_metric_snapshots_job_id_fkey', 'job_id', 'CASCADE'), + # FKs to translation_runs.id + ('translation_batches', 'translation_batches_run_id_fkey', 'run_id', 'CASCADE'), + ('translation_records', 'translation_records_run_id_fkey', 'run_id', 'CASCADE'), + ('translation_events', 'translation_events_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_preview_sessions', 'translation_preview_sessions_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_metric_snapshots', 'translation_metric_snapshots_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_run_language_stats', 'translation_run_language_stats_run_id_fkey', 'run_id', 'CASCADE'), + # FKs to translation_batches.id + ('translation_records', 'translation_records_batch_id_fkey', 'batch_id', 'CASCADE'), + # FKs to translation_records.id + ('translation_languages', 'translation_languages_record_id_fkey', 'record_id', 'CASCADE'), + # FKs to translation_preview_sessions.id + ('translation_preview_records', 'translation_preview_records_session_id_fkey', 'session_id', 'CASCADE'), + # FKs to translation_preview_records.id + ('translation_preview_languages', 'translation_preview_languages_preview_record_id_fkey', 'preview_record_id', 'CASCADE'), +] + + +def upgrade() -> None: + """Upgrade schema.""" + for table, constraint, column, ondelete in FK_FIXES: + op.drop_constraint(constraint, table, type_='foreignkey') + op.create_foreign_key( + constraint, table, 'translation_jobs' if 'job_id' in column else ( + 'translation_runs' if 'run_id' in column else ( + 'translation_batches' if 'batch_id' in column else ( + 'translation_records' if 'record_id' in column and 'preview' not in constraint else ( + 'translation_preview_sessions' if 'session_id' in column else ( + 'translation_preview_records' if 'preview_record_id' in column else 'unknown' + ) + ) + ) + ) + ), + [column], ['id'], + ondelete=ondelete, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + ref_map = { + 'job_id': 'translation_jobs', + 'run_id': 'translation_runs', + 'batch_id': 'translation_batches', + 'record_id': 'translation_records', + 'session_id': 'translation_preview_sessions', + 'preview_record_id': 'translation_preview_records', + } + for table, constraint, column, _ondelete in reversed(FK_FIXES): + ref_table = ref_map[column] + op.drop_constraint(constraint, table, type_='foreignkey') + op.create_foreign_key( + constraint, table, ref_table, + [column], ['id'], + ) diff --git a/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py new file mode 100644 index 00000000..b9471fb5 --- /dev/null +++ b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py @@ -0,0 +1,53 @@ +"""cascade ondelete for all remaining environments FK references + +Revision ID: f0e9d8c7b6a5 +Revises: a5b6c7d8e9f0, e5f4d3c2b1a +Create Date: 2026-05-21 19:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'f0e9d8c7b6a5' +down_revision: Union[str, Sequence[str], None] = ('a5b6c7d8e9f0', 'e5f4d3c2b1a') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +FK_DEFS = [ + ('resource_mappings', 'resource_mappings_environment_id_fkey', 'environment_id'), + ('database_mappings', 'database_mappings_source_env_id_fkey', 'source_env_id'), + ('database_mappings', 'database_mappings_target_env_id_fkey', 'target_env_id'), + ('migration_jobs', 'migration_jobs_source_env_id_fkey', 'source_env_id'), + ('migration_jobs', 'migration_jobs_target_env_id_fkey', 'target_env_id'), + ('dataset_review_sessions', 'dataset_review_sessions_environment_id_fkey', 'environment_id'), +] + + +def upgrade() -> None: + """Upgrade schema.""" + for table, constraint_name, column in FK_DEFS: + op.drop_constraint(constraint_name, table, type_='foreignkey') + op.create_foreign_key( + constraint_name, + table, + 'environments', + [column], + ['id'], + ondelete='CASCADE', + ) + + +def downgrade() -> None: + """Downgrade schema.""" + for table, constraint_name, column in reversed(FK_DEFS): + op.drop_constraint(constraint_name, table, type_='foreignkey') + op.create_foreign_key( + constraint_name, + table, + 'environments', + [column], + ['id'], + ) diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index dd2dcdc6..1c85bfbd 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -437,6 +437,7 @@ async def get_consolidated_settings( "api_key": "********", "default_model": p.default_model, "is_active": p.is_active, + "is_multimodal": bool(p.is_multimodal) if p.is_multimodal is not None else False, } for p in providers ] diff --git a/backend/src/models/dataset_review_pkg/_session_models.py b/backend/src/models/dataset_review_pkg/_session_models.py index 965dbc30..43e804f5 100644 --- a/backend/src/models/dataset_review_pkg/_session_models.py +++ b/backend/src/models/dataset_review_pkg/_session_models.py @@ -74,7 +74,7 @@ class DatasetReviewSession(Base): session_id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) user_id = Column(String, ForeignKey("users.id"), nullable=False) - environment_id = Column(String, ForeignKey("environments.id"), nullable=False) + environment_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) source_kind = Column(String, nullable=False) source_input = Column(String, nullable=False) dataset_ref = Column(String, nullable=False) diff --git a/backend/src/models/mapping.py b/backend/src/models/mapping.py index 15735550..5f765454 100644 --- a/backend/src/models/mapping.py +++ b/backend/src/models/mapping.py @@ -58,8 +58,8 @@ class DatabaseMapping(Base): __tablename__ = "database_mappings" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - source_env_id = Column(String, ForeignKey("environments.id"), nullable=False) - target_env_id = Column(String, ForeignKey("environments.id"), nullable=False) + source_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) + target_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) source_db_uuid = Column(String, nullable=False) target_db_uuid = Column(String, nullable=False) source_db_name = Column(String, nullable=False) @@ -73,8 +73,8 @@ class MigrationJob(Base): __tablename__ = "migration_jobs" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - source_env_id = Column(String, ForeignKey("environments.id"), nullable=False) - target_env_id = Column(String, ForeignKey("environments.id"), nullable=False) + source_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) + target_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING) replace_db = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -88,7 +88,7 @@ class ResourceMapping(Base): __tablename__ = "resource_mappings" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - environment_id = Column(String, ForeignKey("environments.id"), nullable=False) + environment_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) resource_type = Column(SQLEnum(ResourceType), nullable=False) uuid = Column(String, nullable=False) remote_integer_id = Column(String, nullable=False) # Stored as string to handle potentially large or composite IDs safely, though Superset usually uses integers. diff --git a/backend/src/models/task.py b/backend/src/models/task.py index 35981ba2..a1ac64b5 100644 --- a/backend/src/models/task.py +++ b/backend/src/models/task.py @@ -22,7 +22,7 @@ class TaskRecord(Base): id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) type = Column(String, nullable=False) # e.g., "backup", "migration" status = Column(String, nullable=False) # Enum: "PENDING", "RUNNING", "SUCCESS", "FAILED" - environment_id = Column(String, ForeignKey("environments.id"), nullable=True) + environment_id = Column(String, ForeignKey("environments.id", ondelete="SET NULL"), nullable=True) started_at = Column(DateTime(timezone=True), nullable=True) finished_at = Column(DateTime(timezone=True), nullable=True) logs = Column(JSON, nullable=True) # Store structured logs as JSON (legacy, kept for backward compatibility) diff --git a/backend/src/models/translate.py b/backend/src/models/translate.py index 188ec60a..23e5269f 100644 --- a/backend/src/models/translate.py +++ b/backend/src/models/translate.py @@ -71,7 +71,7 @@ class TranslationRun(Base): __tablename__ = "translation_runs" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) status = Column(String, nullable=False, default="PENDING") # PENDING, RUNNING, COMPLETED, FAILED, CANCELLED trigger_type = Column(String, nullable=True, comment="manual, scheduled, retry, baseline_expired") started_at = Column(DateTime, nullable=True) @@ -101,7 +101,7 @@ class TranslationBatch(Base): __tablename__ = "translation_batches" id = Column(String, primary_key=True, default=generate_uuid) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=False, index=True) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False, index=True) batch_index = Column(Integer, nullable=False) status = Column(String, nullable=False, default="PENDING") total_records = Column(Integer, default=0) @@ -119,8 +119,8 @@ class TranslationRecord(Base): __tablename__ = "translation_records" id = Column(String, primary_key=True, default=generate_uuid) - batch_id = Column(String, ForeignKey("translation_batches.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=False, index=True) + batch_id = Column(String, ForeignKey("translation_batches.id", ondelete="CASCADE"), nullable=False, index=True) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False, index=True) source_sql = Column(Text, nullable=True) target_sql = Column(Text, nullable=True) source_object_type = Column(String, nullable=True) # query, dashboard, chart, dataset @@ -151,8 +151,8 @@ class TranslationEvent(Base): __tablename__ = "translation_events" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True, index=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True, index=True) event_type = Column(String, nullable=False) # JOB_CREATED, JOB_STARTED, JOB_COMPLETED, JOB_FAILED, etc. event_data = Column(JSON, nullable=True) created_by = Column(String, nullable=True) @@ -166,8 +166,8 @@ class TranslationPreviewSession(Base): __tablename__ = "translation_preview_sessions" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True) status = Column(String, nullable=False, default="ACTIVE") # ACTIVE, APPLIED, DISCARDED, EXPIRED created_by = Column(String, nullable=True) created_at = Column(DateTime, default=lambda: datetime.now(UTC)) @@ -181,7 +181,7 @@ class TranslationPreviewRecord(Base): __tablename__ = "translation_preview_records" id = Column(String, primary_key=True, default=generate_uuid) - session_id = Column(String, ForeignKey("translation_preview_sessions.id"), nullable=False, index=True) + session_id = Column(String, ForeignKey("translation_preview_sessions.id", ondelete="CASCADE"), nullable=False, index=True) source_sql = Column(Text, nullable=True) target_sql = Column(Text, nullable=True) source_object_type = Column(String, nullable=True) @@ -219,7 +219,7 @@ class DictionaryEntry(Base): __tablename__ = "dictionary_entries" id = Column(String, primary_key=True, default=generate_uuid) - dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False, index=True) + dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id", ondelete="CASCADE"), nullable=False, index=True) source_term = Column(String, nullable=False) source_term_normalized = Column(String, nullable=False) target_term = Column(String, nullable=False) @@ -254,7 +254,7 @@ class TranslationSchedule(Base): __tablename__ = "translation_schedules" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) cron_expression = Column(String, nullable=False) timezone = Column(String, nullable=False, default="UTC") is_active = Column(Boolean, default=True) @@ -273,8 +273,8 @@ class TranslationJobDictionary(Base): __tablename__ = "translation_job_dictionaries" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False, index=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id", ondelete="CASCADE"), nullable=False, index=True) created_at = Column(DateTime, default=lambda: datetime.now(UTC)) __table_args__ = ( @@ -292,8 +292,8 @@ class MetricSnapshot(Base): __tablename__ = "translation_metric_snapshots" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True, index=True) + job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True, index=True) key_hash = Column(String, nullable=False, comment="Hash of dimension key fields for aggregation") config_hash = Column(String, nullable=True, comment="Hash of translation configuration state") dict_snapshot_hash = Column(String, nullable=True, comment="Hash of dictionary state at capture time") @@ -324,7 +324,7 @@ class TranslationLanguage(Base): __tablename__ = "translation_languages" id = Column(String, primary_key=True, default=generate_uuid) - record_id = Column(String, ForeignKey("translation_records.id"), nullable=False) + record_id = Column(String, ForeignKey("translation_records.id", ondelete="CASCADE"), nullable=False) language_code = Column(String, nullable=False, comment="BCP-47 language code") source_language_detected = Column(String, nullable=True, comment="BCP-47 or 'und' for undetermined") translated_value = Column(Text, nullable=True, comment="LLM-generated translation") @@ -352,7 +352,7 @@ class TranslationPreviewLanguage(Base): __tablename__ = "translation_preview_languages" id = Column(String, primary_key=True, default=generate_uuid) - preview_record_id = Column(String, ForeignKey("translation_preview_records.id"), nullable=False) + preview_record_id = Column(String, ForeignKey("translation_preview_records.id", ondelete="CASCADE"), nullable=False) language_code = Column(String, nullable=False, comment="BCP-47 language code") source_language_detected = Column(String, nullable=True, comment="BCP-47 or 'und'") translated_value = Column(Text, nullable=True) @@ -376,7 +376,7 @@ class TranslationRunLanguageStats(Base): __tablename__ = "translation_run_language_stats" id = Column(String, primary_key=True, default=generate_uuid) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=False) + run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False) language_code = Column(String, nullable=False, comment="BCP-47 language code") total_rows = Column(Integer, default=0) translated_rows = Column(Integer, default=0) diff --git a/frontend/src/components/llm/ProviderConfig.svelte b/frontend/src/components/llm/ProviderConfig.svelte index cbb09a62..cf8df440 100644 --- a/frontend/src/components/llm/ProviderConfig.svelte +++ b/frontend/src/components/llm/ProviderConfig.svelte @@ -246,7 +246,6 @@ base_url: provider.base_url, default_model: provider.default_model, is_active: provider.is_active, - is_multimodal: Boolean(provider.is_multimodal), }; await requestApi(`/llm/providers/${provider.id}`, "PUT", updatePayload); } catch (err) { diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 89a5ce5d..17ddf5bf 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -3,7 +3,6 @@ // @RELATION DEPENDS_ON -> [ToastsModule] // @RELATION CALLED_BY -> [TranslateApi] import { addToast } from './toasts.js'; -import { PUBLIC_WS_URL } from '$env/static/public'; const API_BASE_URL = '/api'; @@ -39,12 +38,9 @@ function shouldSuppressApiErrorToast(endpoint, error) { } export const getWsUrl = (taskId) => { - let baseUrl = PUBLIC_WS_URL; - if (!baseUrl) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - baseUrl = `${protocol}//${window.location.host}`; - } - return `${baseUrl}/ws/logs/${taskId}`; + const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000'; + return `${protocol}//${host}/ws/logs/${taskId}`; }; function getAuthHeaders(extraHeaders = {}) { diff --git a/frontend/src/lib/components/ui/SearchableMultiSelect.svelte b/frontend/src/lib/components/ui/SearchableMultiSelect.svelte new file mode 100644 index 00000000..1f63389a --- /dev/null +++ b/frontend/src/lib/components/ui/SearchableMultiSelect.svelte @@ -0,0 +1,246 @@ + + + + + + + + + + +
+ {#if label} + + {/if} + + +
+ + {#if loading} +
+ {:else if selected.length > 0 && !isOpen} +
+ {selected.length} +
+ {/if} +
+ + + {#if selected.length > 0} +
+ {#each selected as id} + {@const item = getItemById(id)} + {#if item} + + {item.name} + + + {/if} + {/each} + +
+ {/if} + + + {#if isOpen} +
+ + {#if filteredOptions.length > 0} +
+ + {#if selected.length > 0} + + {/if} +
+ {/if} + + +
+ {#if loading && options.length === 0} +
+
+

Loading...

+
+ {:else if filteredOptions.length === 0} +
+ {emptyMessage} +
+ {:else} + {#each filteredOptions as opt} + + {/each} + {/if} +
+
+ {/if} +
+ diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index 773361b5..2dcaa1bc 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -64,6 +64,7 @@ import { gitService } from "../../services/gitService.js"; import MappingTable from "../../components/MappingTable.svelte"; import StartupEnvironmentWizard from "../../components/StartupEnvironmentWizard.svelte"; + import SearchableMultiSelect from "$lib/components/ui/SearchableMultiSelect.svelte"; import { environmentContextStore, initializeEnvironmentContext, @@ -91,6 +92,9 @@ // Search state let searchQuery = $state(""); + let filterDashboardIds = $state([]); // IDs selected via SearchableMultiSelect + let searchableDashboardOptions = $state([]); + let searchableDashboardLoading = $state(false); let sortColumn = $state("title"); let sortDirection = $state("asc"); let openFilterColumn = $state(null); @@ -398,6 +402,31 @@ debouncedSearch(event.target.value); } + // Load dashboard options for SearchableMultiSelect + async function loadDashboardSearchOptions(query = '') { + if (!selectedEnv) return; + searchableDashboardLoading = true; + try { + const response = await api.getDashboards(selectedEnv, { + search: query || undefined, + page_size: 100, + page_context: 'other', + apply_profile_default: false, + override_show_all: true, + }); + searchableDashboardOptions = (response?.dashboards || []).map((d) => ({ + id: d.id, + name: d.title, + subtitle: d.slug || '', + hint: d.last_modified ? formatDate(d.last_modified) : '', + })); + } catch { + searchableDashboardOptions = []; + } finally { + searchableDashboardLoading = false; + } + } + // Handle page change function handlePageChange(page) { if (page === currentPage) return; @@ -779,9 +808,13 @@ * @UX_STATE: Loaded -> visible rows reflect all active controls deterministically. */ function applyGridTransforms() { - const nextFiltered = allDashboards - // Search and column filters are server-side. - .filter(() => true); + let nextFiltered = allDashboards; + // Client-side multi-select filter (SearchableMultiSelect) + if (filterDashboardIds.length > 0) { + nextFiltered = nextFiltered.filter((d) => + filterDashboardIds.includes(d.id), + ); + } nextFiltered.sort((a, b) => { const aValue = getSortValue(a, sortColumn); @@ -1464,6 +1497,14 @@ } }); + // Re-apply grid transforms when multi-select filter changes (guarded against loops) + $effect(() => { + filterDashboardIds; + queueMicrotask(() => { + if (allDashboards.length > 0) applyGridTransforms(); + }); + }); + $effect(() => { const envId = selectedEnv; if (!envId || envId === lastLoadedEnvId) return; @@ -1472,7 +1513,9 @@ profileFilterOverrideShowAll = false; effectiveProfileFilter = null; clearSelectedIds(); + filterDashboardIds = []; void loadDashboards(); + void loadDashboardSearchOptions(''); }); async function handleEnvironmentCreated(envId) { @@ -1669,13 +1712,17 @@ {/if} -
- +
diff --git a/frontend/src/routes/validation/[id]/+page.svelte b/frontend/src/routes/validation/[id]/+page.svelte index b22aad75..53fffb0f 100644 --- a/frontend/src/routes/validation/[id]/+page.svelte +++ b/frontend/src/routes/validation/[id]/+page.svelte @@ -20,6 +20,7 @@ fetchTask, createTask, updateTask, triggerRun, fetchRuns } from '$lib/api/validation.js'; + import SearchableMultiSelect from '$lib/components/ui/SearchableMultiSelect.svelte'; let isMounted = $state(true); @@ -36,7 +37,7 @@ // Form state let name = $state(''); - let dashboardId = $state(''); + let dashboardIds = $state([]); let environmentId = $state(''); let providerId = $state(''); let isActive = $state(true); @@ -82,12 +83,18 @@ : (task?.name || name || ($t.validation?.edit_task || 'Edit Validation Task')) ); - // ── Provider is multimodal check ── + // ── Provider multimodal filter ── + function isProviderMultimodalCheck(p) { + return !!(p.is_multimodal || p.supports_images || p.multimodal); + } + let multimodalProviders = $derived( + llmProviders.filter(isProviderMultimodalCheck) + ); let selectedProvider = $derived( - llmProviders.find(p => String(p.id) === String(providerId)) + multimodalProviders.find(p => String(p.id) === String(providerId)) ); let isProviderMultimodal = $derived( - selectedProvider?.supports_images || selectedProvider?.multimodal || false + selectedProvider ? isProviderMultimodalCheck(selectedProvider) : false ); onMount(async () => { @@ -119,15 +126,13 @@ } catch { llmProviders = []; } - - await searchDashboards(''); } async function searchDashboards(search = '') { dashboardsLoading = true; try { - const result = await api.getDashboards?.(environmentId || '', { search, page_size: 50 }) || []; - dashboards = Array.isArray(result) ? result : (result?.results || result?.items || []); + const result = await api.getDashboards?.(environmentId || '', { search, page_size: 50 }) || {}; + dashboards = result?.dashboards || []; } catch { dashboards = []; } finally { @@ -138,15 +143,16 @@ async function loadTask() { uxState = 'loading'; try { - task = await fetchTask(taskId); + const response = await fetchTask(taskId); if (!isMounted) return; - if (!task) { + if (!response || !response.task) { uxState = 'not_found'; return; } + task = response.task; name = task.name || ''; - dashboardId = task.dashboard_ids?.[0] || ''; + dashboardIds = task.dashboard_ids || []; environmentId = task.environment_id || ''; providerId = task.provider_id || ''; isActive = task.is_active !== false; @@ -196,7 +202,7 @@ function validate() { const errors = {}; if (!name.trim()) errors.name = $t.validation?.form_validation_required || 'This field is required'; - if (!dashboardId) errors.dashboard_id = $t.validation?.form_validation_required || 'This field is required'; + if (dashboardIds.length === 0) errors.dashboard_id = $t.validation?.form_validation_required || 'This field is required'; if (!environmentId) errors.environment_id = $t.validation?.form_validation_required || 'This field is required'; if (!providerId) errors.provider_id = $t.validation?.form_validation_required || 'This field is required'; validationErrors = errors; @@ -206,7 +212,7 @@ function buildPayload() { return { name: name.trim(), - dashboard_ids: dashboardId ? [dashboardId] : [], + dashboard_ids: dashboardIds, environment_id: environmentId || null, provider_id: providerId || null, is_active: isActive, @@ -307,7 +313,7 @@ {isNewTask ? ($t.validation?.new_task || 'New Validation Task') : ($t.validation?.edit_task || 'Edit Validation Task')} {#if !isNewTask && task} -

{$t.validation?.created_at || 'Created'}: {new Date(task.created_at || task.created_on).toLocaleString()}

+

{$t.validation?.created_at || 'Created'}: {task?.created_at ? new Date(task.created_at).toLocaleString() : '-'}

{/if}
@@ -456,7 +462,7 @@ - - {#each dashboards as d (d.id)} - - {/each} - + ({ id: String(d.id), name: d.title || d.dashboard_title || d.name || String(d.id), subtitle: d.schema || '' }))} + bind:selected={dashboardIds} + placeholder={$t.validation?.dashboard || 'Select dashboard...'} + selectAllLabel={$t.dashboard?.select_all || 'Select All'} + clearLabel="Clear" + emptyMessage="No dashboards found" + disabled={!environmentId} + /> {#if validationErrors.dashboard_id}

{validationErrors.dashboard_id}

{/if} @@ -506,12 +510,9 @@ class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.provider_id ? 'border-red-300 bg-red-50' : 'border-gray-300'}" > - {#each llmProviders as p (p.id)} + {#each multimodalProviders as p (p.id)} {/each}