Files
ss-tools/frontend/src/routes/dashboards/[id]/+page.svelte

1058 lines
40 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- [DEF:DashboardDetail:Page] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Dashboard Detail View - Overview of charts and datasets linked to a dashboard
* @LAYER: UI
* @RELATION: BINDS_TO -> dashboard detail API
* @INVARIANT: Shows dashboard metadata, charts, and datasets for selected environment
* @TEST_DATA: dashboard_detail_ready -> {"dashboard":{"id":11,"title":"Ops","chart_count":3,"dataset_count":2},"taskHistory":[{"id":"t-1","plugin_id":"llm_dashboard_validation","status":"SUCCESS"}],"llmStatus":{"configured":true,"reason":"ok"}}
* @TEST_DATA: llm_unconfigured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}}
*
* @TEST_CONTRACT Page_DashboardDetail ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Loads specific dashboard details directly via the API leveraging the id parameter",
* "Triggers LLM validation gracefully respecting llmReady status"
* ]
* }
* @TEST_FIXTURE init_state -> {"id": "1", "env_id": "env1"}
* @TEST_INVARIANT dashboard_fetching -> verifies: [init_state]
*/
import { onMount, onDestroy } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import { gitService } from "../../../services/gitService";
import {
openDrawerForTaskIfPreferred,
} from "$lib/stores/taskDrawer.js";
import { addToast } from "$lib/toasts.js";
import Icon from "$lib/ui/Icon.svelte";
import BranchSelector from "../../../components/git/BranchSelector.svelte";
import CommitHistory from "../../../components/git/CommitHistory.svelte";
import GitManager from "../../../components/git/GitManager.svelte";
$: dashboardRef = $page.params.id;
$: envId = $page.url.searchParams.get("env_id") || "";
$: gitDashboardRef = dashboard?.slug || dashboardRef || "";
$: resolvedDashboardId =
dashboard?.id ??
(/^\d+$/.test(String(dashboardRef || "")) ? Number(dashboardRef) : null);
let dashboard = null;
let isLoading = true;
let error = null;
let taskHistory = [];
let isTaskHistoryLoading = false;
let taskHistoryError = null;
let isStartingBackup = false;
let isStartingValidation = false;
let thumbnailUrl = "";
let isThumbnailLoading = false;
let thumbnailError = null;
let llmReady = true;
let llmStatusReason = "";
let gitStatus = null;
let isGitStatusLoading = false;
let gitStatusError = null;
let gitDiffPreview = "";
let isGitDiffLoading = false;
let isSyncingGit = false;
let isPullingGit = false;
let isPushingGit = false;
let currentBranch = "main";
let activeTab = "resources";
let showGitManager = false;
let wasGitManagerOpen = false;
let gitMeta = getGitStatusMeta();
let gitSyncState = "NO_REPO";
let changedChartsCount = 0;
let changedDatasetsCount = 0;
let hasChangesToCommit = false;
onMount(async () => {
await loadDashboardPage();
});
onDestroy(() => {
releaseThumbnailUrl();
});
async function loadDashboardPage() {
await loadDashboardDetail();
const effectiveDashboardRef = dashboard?.id ?? dashboardRef;
await Promise.all([
loadTaskHistory(effectiveDashboardRef),
loadThumbnail(false, effectiveDashboardRef),
loadLlmStatus(),
loadGitStatus(effectiveDashboardRef),
]);
}
async function loadDashboardDetail() {
if (!dashboardRef || !envId) {
error = $t.dashboard?.missing_context;
isLoading = false;
return;
}
isLoading = true;
error = null;
try {
dashboard = await api.getDashboardDetail(envId, dashboardRef);
} catch (err) {
error = err.message || $t.dashboard?.load_detail_failed;
console.error("[DashboardDetail][Coherence:Failed]", err);
} finally {
isLoading = false;
}
}
async function loadTaskHistory(targetDashboardRef = dashboardRef) {
if (!targetDashboardRef || !envId) return;
isTaskHistoryLoading = true;
taskHistoryError = null;
try {
const response = await api.getDashboardTaskHistory(envId, targetDashboardRef, {
limit: 30,
});
taskHistory = response?.items || [];
} catch (err) {
taskHistoryError = err.message || "Failed to load task history";
taskHistory = [];
} finally {
isTaskHistoryLoading = false;
}
}
function releaseThumbnailUrl() {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
thumbnailUrl = "";
}
}
async function loadThumbnail(force = false, targetDashboardRef = dashboardRef) {
if (!targetDashboardRef || !envId) return;
isThumbnailLoading = true;
thumbnailError = null;
try {
const blob = await api.getDashboardThumbnail(envId, targetDashboardRef, {
force,
});
releaseThumbnailUrl();
thumbnailUrl = URL.createObjectURL(blob);
} catch (err) {
if (err?.status === 202) {
thumbnailError =
$t.dashboard?.thumbnail_generating || "Thumbnail is being generated";
} else {
thumbnailError =
err.message ||
$t.dashboard?.thumbnail_failed ||
"Failed to load thumbnail";
}
} finally {
isThumbnailLoading = false;
}
}
async function runBackupTask() {
if (isStartingBackup || !envId || !resolvedDashboardId) return;
isStartingBackup = true;
try {
const response = await api.postApi("/dashboards/backup", {
env_id: envId,
dashboard_ids: [resolvedDashboardId],
});
const taskId = response?.task_id;
if (taskId) {
openDrawerForTaskIfPreferred(taskId);
addToast(
$t.dashboard?.backup_started || "Backup task started",
"success",
);
}
await loadTaskHistory();
} catch (err) {
addToast(
err.message ||
$t.dashboard?.backup_task_failed ||
"Failed to start backup",
"error",
);
} finally {
isStartingBackup = false;
}
}
async function runLlmValidationTask() {
if (!llmReady) {
addToast(
$t.dashboard?.llm_not_configured || "LLM is not configured",
"error",
);
return;
}
if (isStartingValidation || !envId || !resolvedDashboardId) return;
isStartingValidation = true;
try {
const response = await api.postApi("/tasks", {
plugin_id: "llm_dashboard_validation",
params: {
dashboard_id: String(resolvedDashboardId),
environment_id: envId,
},
});
const taskId = response?.task_id || response?.id;
if (taskId) {
openDrawerForTaskIfPreferred(taskId);
addToast(
$t.dashboard?.validation_started || "LLM validation started",
"success",
);
}
await Promise.all([loadTaskHistory(), loadThumbnail(true)]);
} catch (err) {
addToast(
err.message ||
$t.dashboard?.validation_start_failed ||
"Failed to start LLM validation",
"error",
);
} finally {
isStartingValidation = false;
}
}
function openLlmReport(taskId) {
if (!taskId) return;
window.open(
`/reports/llm/${encodeURIComponent(String(taskId))}`,
"_blank",
"noopener,noreferrer",
);
}
function toTaskTypeLabel(pluginId) {
if (pluginId === "superset-backup") return $t.dashboard?.backup || "Backup";
if (pluginId === "llm_dashboard_validation")
return $t.dashboard?.llm_check || "LLM Check";
return pluginId || "-";
}
function getTaskStatusClasses(status) {
const normalized = (status || "").toLowerCase();
if (normalized === "running" || normalized === "pending")
return "bg-blue-100 text-blue-700";
if (normalized === "success") return "bg-emerald-100 text-emerald-700";
if (normalized === "failed" || normalized === "error")
return "bg-rose-100 text-rose-700";
if (normalized === "awaiting_input" || normalized === "waiting_input")
return "bg-amber-100 text-amber-700";
return "bg-slate-100 text-slate-700";
}
function getValidationStatus(task) {
if (task?.plugin_id !== "llm_dashboard_validation") {
return { label: "-", level: "na", icon: "" };
}
const rawStatus = String(task?.validation_status || "").toUpperCase();
if (rawStatus === "FAIL") {
return { label: "FAIL", level: "fail", icon: "!" };
}
if (rawStatus === "WARN") {
return { label: "WARN", level: "warn", icon: "!" };
}
if (rawStatus === "PASS") {
return { label: "PASS", level: "pass", icon: "OK" };
}
return { label: "UNKNOWN", level: "unknown", icon: "?" };
}
function getValidationStatusClasses(level) {
if (level === "fail") return "bg-rose-100 text-rose-700 border-rose-200";
if (level === "warn") return "bg-amber-100 text-amber-700 border-amber-200";
if (level === "pass")
return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (level === "unknown")
return "bg-slate-100 text-slate-700 border-slate-200";
return "bg-slate-50 text-slate-400 border-slate-200";
}
function goBack() {
goto(`/dashboards?env_id=${encodeURIComponent(envId)}`);
}
function openDataset(datasetId) {
goto(`/datasets/${datasetId}?env_id=${encodeURIComponent(envId)}`);
}
function formatDate(value) {
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return `${parsed.toLocaleDateString()} ${parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
async function loadLlmStatus() {
try {
const status = await api.getLlmStatus();
llmReady = Boolean(status?.configured);
llmStatusReason = status?.reason || "";
} catch (_err) {
llmReady = false;
llmStatusReason = "status_unavailable";
}
}
function hasGitRepository() {
if (!gitStatus) return false;
if (gitStatus.has_repo === false) return false;
return gitStatus.sync_state !== "NO_REPO" || Boolean(gitStatus.current_branch);
}
function resolveGitSyncState() {
if (!gitStatus || gitStatus.has_repo === false) return "NO_REPO";
const explicitState = String(gitStatus.sync_state || "").toUpperCase();
const changedCount =
(gitStatus?.modified_files?.length || 0) +
(gitStatus?.staged_files?.length || 0) +
(gitStatus?.untracked_files?.length || 0);
const ahead = Number(gitStatus?.ahead_count || 0);
const behind = Number(gitStatus?.behind_count || 0);
if (
explicitState &&
explicitState !== "UNKNOWN" &&
explicitState !== "NO_REPO"
) {
return explicitState;
}
if (changedCount > 0) return "CHANGES";
if (ahead > 0 && behind > 0) return "DIVERGED";
if (ahead > 0) return "AHEAD_REMOTE";
if (behind > 0) return "BEHIND_REMOTE";
if (gitStatus?.current_branch) return "SYNCED";
return "NO_REPO";
}
function allChangedFiles() {
if (!gitStatus) return [];
const staged = gitStatus.staged_files || [];
const modified = gitStatus.modified_files || [];
const untracked = gitStatus.untracked_files || [];
return Array.from(new Set([...staged, ...modified, ...untracked]));
}
function countChangedByAnyPath(fragments) {
return allChangedFiles().filter((file) =>
fragments.some((fragment) => file.includes(fragment)),
).length;
}
function getGitStatusMeta() {
const syncState = resolveGitSyncState();
if (syncState === "SYNCED") {
return {
dotClass: "bg-emerald-500",
pillClass: "bg-emerald-100 text-emerald-800 border-emerald-200",
label: $t.git?.repo_status?.synced || "Synced",
};
}
if (syncState === "CHANGES") {
return {
dotClass: "bg-amber-500",
pillClass: "bg-amber-100 text-amber-800 border-amber-200",
label: $t.git?.repo_status?.changes || "Modified",
};
}
if (syncState === "AHEAD_REMOTE") {
return {
dotClass: "bg-blue-500",
pillClass: "bg-blue-100 text-blue-800 border-blue-200",
label: $t.git?.repo_status?.ahead_remote || "Ahead",
};
}
if (syncState === "BEHIND_REMOTE") {
return {
dotClass: "bg-indigo-500",
pillClass: "bg-indigo-100 text-indigo-800 border-indigo-200",
label: $t.git?.repo_status?.behind_remote || "Behind",
};
}
if (syncState === "DIVERGED") {
return {
dotClass: "bg-rose-500",
pillClass: "bg-rose-100 text-rose-800 border-rose-200",
label: $t.git?.repo_status?.diverged || "Diverged",
};
}
return {
dotClass: "bg-slate-400",
pillClass: "bg-slate-100 text-slate-700 border-slate-200",
label: $t.git?.repo_status?.no_repo || "Uninitialized",
};
}
async function loadGitStatus(targetDashboardRef = gitDashboardRef) {
if (!targetDashboardRef) return;
isGitStatusLoading = true;
gitStatusError = null;
gitDiffPreview = "";
try {
const status = await gitService.getStatus(targetDashboardRef, envId || null);
gitStatus = status;
if (status?.current_branch) {
currentBranch = status.current_branch;
}
} catch (err) {
gitStatusError = err.message || "Failed to load Git status";
gitStatus = {
sync_state: "NO_REPO",
current_branch: null,
has_repo: false,
modified_files: [],
staged_files: [],
untracked_files: [],
ahead_count: 0,
behind_count: 0,
};
} finally {
isGitStatusLoading = false;
}
}
async function loadGitDiffPreview() {
if (!hasGitRepository()) return;
isGitDiffLoading = true;
try {
const staged = await gitService.getDiff(gitDashboardRef, null, true, envId || null);
const unstaged = await gitService.getDiff(
gitDashboardRef,
null,
false,
envId || null,
);
gitDiffPreview = [staged, unstaged].filter(Boolean).join("\n\n");
if (!gitDiffPreview) {
addToast($t.git?.no_changes || "No changes detected", "info");
}
} catch (err) {
addToast(err.message || "Failed to load diff", "error");
} finally {
isGitDiffLoading = false;
}
}
async function runGitSyncAndOpenCommit() {
if (!hasGitRepository()) {
addToast($t.git?.not_linked || "Repository is not initialized", "error");
return;
}
if (isSyncingGit) return;
isSyncingGit = true;
try {
await gitService.sync(gitDashboardRef, envId || null, envId || null);
addToast($t.git?.sync_success || "Dashboard state synced to Git", "success");
showGitManager = true;
await loadGitStatus();
} catch (err) {
addToast(err.message || "Git sync failed", "error");
} finally {
isSyncingGit = false;
}
}
async function runGitPull() {
if (!hasGitRepository() || isPullingGit) return;
isPullingGit = true;
try {
await gitService.pull(gitDashboardRef, envId || null);
addToast($t.git?.pull_success || "Changes pulled from remote", "success");
await loadGitStatus();
} catch (err) {
addToast(err.message || "Git pull failed", "error");
} finally {
isPullingGit = false;
}
}
async function runGitPush() {
if (!hasGitRepository() || isPushingGit) return;
isPushingGit = true;
try {
await gitService.push(gitDashboardRef, envId || null);
addToast($t.git?.push_success || "Changes pushed to remote", "success");
await loadGitStatus();
} catch (err) {
addToast(err.message || "Git push failed", "error");
} finally {
isPushingGit = false;
}
}
async function handleBranchChange(event) {
const nextBranch = event?.detail?.branch;
if (!nextBranch) return;
currentBranch = nextBranch;
await loadGitStatus();
}
$: {
gitStatus;
$t;
gitMeta = getGitStatusMeta();
gitSyncState = resolveGitSyncState();
changedChartsCount = countChangedByAnyPath(["/charts/", "charts/"]);
changedDatasetsCount = countChangedByAnyPath(["/datasets/", "datasets/"]);
hasChangesToCommit = allChangedFiles().length > 0;
}
$: if (showGitManager) {
wasGitManagerOpen = true;
} else if (wasGitManagerOpen) {
wasGitManagerOpen = false;
loadGitStatus();
}
</script>
<div class="mx-auto w-full max-w-7xl space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-2">
<button
class="inline-flex items-center gap-2 rounded-lg px-2 py-1 text-sm text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
on:click={goBack}
>
<Icon name="chevronLeft" size={16} />
{$t.common?.back}
</button>
<div class="flex flex-wrap items-center gap-3">
<h1 class="text-2xl font-bold text-slate-900">
{dashboard?.title || $t.dashboard?.overview}
</h1>
{#if hasGitRepository() && gitDashboardRef}
<div
class="w-full max-w-xs rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<BranchSelector
dashboardId={gitDashboardRef}
envId={envId || null}
bind:currentBranch
on:change={handleBranchChange}
/>
</div>
{/if}
</div>
<p class="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-500">
<span>{$t.common?.id}: {resolvedDashboardId ?? dashboardRef}{#if dashboard?.slug}{dashboard.slug}{/if}</span>
<span class="text-slate-300">|</span>
<span
class={`inline-flex items-center gap-2 rounded-full border px-2 py-1 text-xs font-semibold ${gitMeta.pillClass}`}
>
<span class={`h-2 w-2 rounded-full ${gitMeta.dotClass}`}></span>
Git: {gitMeta.label}
{#if gitStatus?.ahead_count > 0}
({gitStatus.ahead_count})
{/if}
</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
on:click={() => (showGitManager = true)}
>
{$t.git?.management || "Manage Git"}
</button>
<button
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
on:click={runBackupTask}
disabled={isStartingBackup}
>
{isStartingBackup
? $t.common?.loading || "Loading..."
: $t.dashboard?.run_backup || "Run backup"}
</button>
<button
class="inline-flex items-center justify-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors {llmReady
? 'border-indigo-300 bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
: 'border-rose-300 bg-rose-50 text-rose-700 opacity-70 cursor-not-allowed'}"
on:click={runLlmValidationTask}
disabled={isStartingValidation || !llmReady}
title={!llmReady
? $t.dashboard?.llm_not_configured || "LLM is not configured"
: ""}
>
{isStartingValidation
? $t.common?.loading || "Loading..."
: $t.dashboard?.run_llm_check || "Run LLM check"}
</button>
<button
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
on:click={loadDashboardPage}
>
{$t.common?.refresh}
</button>
</div>
</div>
{#if !llmReady}
<div
class="rounded-lg border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800"
>
<div class="font-semibold">
{$t.dashboard?.llm_not_configured || "LLM is not configured"}
</div>
<div class="mt-1 text-rose-700">
{#if llmStatusReason === "no_active_provider"}
{$t.dashboard?.llm_configure_provider ||
"No active LLM provider. Configure it in Admin -> LLM Settings."}
{:else if llmStatusReason === "invalid_api_key"}
{$t.dashboard?.llm_configure_key ||
"Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."}
{:else}
{$t.dashboard?.llm_status_unavailable ||
"LLM status is unavailable. Check settings and backend logs."}
{/if}
</div>
</div>
{/if}
{#if error}
<div
class="flex items-center justify-between rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-red-700"
>
<span>{error}</span>
<button
class="rounded bg-red-600 px-3 py-1.5 text-white hover:bg-red-700"
on:click={loadDashboardDetail}
>
{$t.common?.retry}
</button>
</div>
{/if}
{#if isLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{#each Array(3) as _}
<div
class="h-24 animate-pulse rounded-xl border border-slate-200 bg-white"
></div>
{/each}
</div>
<div
class="h-64 animate-pulse rounded-xl border border-slate-200 bg-white"
></div>
{:else if dashboard}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-5">
<div class="rounded-xl border border-slate-200 bg-white p-4 xl:col-span-2">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">
{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}
</h2>
<button
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-700 hover:bg-slate-50"
on:click={() => loadThumbnail(true)}
disabled={isThumbnailLoading}
>
{$t.common?.refresh || "Refresh"}
</button>
</div>
{#if isThumbnailLoading}
<div class="h-56 animate-pulse rounded-lg bg-slate-100"></div>
{:else if thumbnailUrl}
<img
src={thumbnailUrl}
alt="Dashboard thumbnail"
class="h-56 w-full rounded-lg border border-slate-200 object-cover"
/>
{:else}
<div
class="flex h-56 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50 text-sm text-slate-500"
>
{thumbnailError ||
$t.dashboard?.thumbnail_unavailable ||
"Thumbnail is unavailable"}
</div>
{/if}
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4 xl:col-span-3">
<div class="mb-3 flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">
{$t.git?.management || "Git Repository"}
</h2>
<button
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-700 hover:bg-slate-50"
on:click={loadGitStatus}
disabled={isGitStatusLoading}
>
{$t.common?.refresh || "Refresh"}
</button>
</div>
{#if isGitStatusLoading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-slate-100"></div>
{/each}
</div>
{:else if gitStatusError}
<div class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{gitStatusError}
</div>
{:else if !hasGitRepository()}
<div class="space-y-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-6 text-sm text-slate-600">
<div>
{$t.git?.not_linked || "Этот дашборд еще не привязан к Git-репозиторию."}
</div>
<button
class="inline-flex items-center justify-center rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-800"
on:click={() => (showGitManager = true)}
>
Инициализировать Git-репозиторий
</button>
</div>
{:else}
<div class="space-y-4">
<p class="text-sm text-slate-700">
{#if gitSyncState === "CHANGES"}
Обнаружены изменения конфигурации в Superset.
{:else}
Конфигурация Superset совпадает с веткой {gitStatus?.current_branch || "main"}.
{/if}
</p>
<div class="flex flex-wrap gap-2 text-xs text-slate-600">
<span class="rounded-full bg-slate-100 px-2 py-1">
charts: {changedChartsCount}
</span>
<span class="rounded-full bg-slate-100 px-2 py-1">
datasets: {changedDatasetsCount}
</span>
<span class="rounded-full bg-slate-100 px-2 py-1">
files: {allChangedFiles().length}
</span>
</div>
<div class="grid gap-2 md:grid-cols-2">
<button
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
on:click={runGitSyncAndOpenCommit}
disabled={isSyncingGit || !hasChangesToCommit}
>
{isSyncingGit
? $t.common?.loading || "Loading..."
: "Синхронизировать и зафиксировать"}
</button>
<button
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-60"
on:click={loadGitDiffPreview}
disabled={isGitDiffLoading || !hasChangesToCommit}
>
{isGitDiffLoading ? $t.common?.loading || "Loading..." : "Показать diff"}
</button>
</div>
<div class="grid gap-2 md:grid-cols-2">
<button
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-60"
on:click={runGitPull}
disabled={isPullingGit}
>
{isPullingGit ? $t.common?.loading || "Loading..." : $t.git?.pull || "Pull"}
</button>
<button
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-60"
on:click={runGitPush}
disabled={isPushingGit}
>
{#if isPushingGit}
{$t.common?.loading || "Loading..."}
{:else}
{$t.git?.push || "Push"}{#if gitStatus?.ahead_count > 0} ({gitStatus.ahead_count}){/if}
{/if}
</button>
</div>
{#if gitDiffPreview}
<div class="max-h-52 overflow-auto rounded-lg border border-slate-200 bg-slate-900 p-3 text-xs text-slate-100">
<pre class="whitespace-pre-wrap font-mono">{gitDiffPreview}</pre>
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dashboard?.last_modified}
</p>
<p class="mt-2 text-lg font-semibold text-slate-900">
{formatDate(dashboard.last_modified)}
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dashboard?.charts}
</p>
<p class="mt-2 text-lg font-semibold text-slate-900">
{dashboard.chart_count || 0}
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.nav?.datasets}
</p>
<p class="mt-2 text-lg font-semibold text-slate-900">
{dashboard.dataset_count || 0}
</p>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 px-4 py-3">
<button
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === "resources" ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-700 hover:bg-slate-200"}`}
on:click={() => (activeTab = "resources")}
>
Linked resources
</button>
<button
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === "git-history" ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-700 hover:bg-slate-200"}`}
on:click={() => (activeTab = "git-history")}
>
Git history
</button>
<button
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === "tasks" ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-700 hover:bg-slate-200"}`}
on:click={() => (activeTab = "tasks")}
>
{$t.nav?.tasks || "Task logs"}
</button>
</div>
<div class="space-y-4 p-4">
{#if activeTab === "resources"}
{#if dashboard.description}
<div class="rounded-xl border border-slate-200 bg-white p-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">
{$t.dashboard?.overview}
</h2>
<p class="mt-2 text-sm text-slate-700">{dashboard.description}</p>
</div>
{/if}
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="border-b border-slate-200 px-4 py-3">
<h2 class="text-lg font-semibold text-slate-900">
{$t.dashboard?.charts}
</h2>
</div>
{#if dashboard.charts && dashboard.charts.length > 0}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-semibold text-slate-600">
{$t.settings?.type_chart}
</th>
<th class="px-4 py-2 text-left font-semibold text-slate-600">
{$t.nav?.datasets}
</th>
<th class="px-4 py-2 text-left font-semibold text-slate-600">
{$t.dashboard?.overview}
</th>
<th class="px-4 py-2 text-left font-semibold text-slate-600">
{$t.dashboard?.last_modified}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each dashboard.charts as chart}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<div class="font-medium text-slate-900">{chart.title}</div>
<div class="text-xs text-slate-500">
ID: {chart.id}{#if chart.viz_type}{chart.viz_type}{/if}
</div>
</td>
<td class="px-4 py-3">
{#if chart.dataset_id}
<button
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-50 hover:text-blue-800"
on:click={() => openDataset(chart.dataset_id)}
title={`${$t.datasets?.table_name} ${chart.dataset_id}`}
>
{$t.nav?.datasets}
{chart.dataset_id}
<Icon name="chevronRight" size={12} className="text-blue-500" />
</button>
{:else}
<span class="text-xs text-slate-400">-</span>
{/if}
</td>
<td class="px-4 py-3 text-slate-700">{chart.overview || "-"}</td>
<td class="px-4 py-3 text-slate-700">{formatDate(chart.last_modified)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="px-4 py-8 text-sm text-slate-500">
{$t.dashboard?.no_charts}
</div>
{/if}
</div>
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="border-b border-slate-200 px-4 py-3">
<h2 class="text-lg font-semibold text-slate-900">{$t.nav?.datasets}</h2>
</div>
{#if dashboard.datasets && dashboard.datasets.length > 0}
<div class="divide-y divide-slate-100">
{#each dashboard.datasets as dataset}
<button
class="flex w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-slate-50"
on:click={() => openDataset(dataset.id)}
>
<div class="min-w-0">
<div class="truncate font-medium text-slate-900">
{dataset.table_name}
</div>
<div class="truncate text-xs text-slate-500">
{dataset.overview || `${dataset.schema || ""}.${dataset.table_name}`}
{#if dataset.database} • {dataset.database}{/if}
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{formatDate(dataset.last_modified)}</span>
<Icon name="chevronRight" size={16} className="text-slate-400" />
</div>
</button>
{/each}
</div>
{:else}
<div class="px-4 py-8 text-sm text-slate-500">
{$t.dashboard?.no_datasets}
</div>
{/if}
</div>
{:else if activeTab === "git-history"}
{#if hasGitRepository() && gitDashboardRef}
<CommitHistory dashboardId={gitDashboardRef} envId={envId || null} />
{:else}
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-600">
{$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."}
</div>
{/if}
{:else}
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">
{$t.tasks?.recent || "Recent tasks"}
</h2>
<button
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-700 hover:bg-slate-50"
on:click={loadTaskHistory}
disabled={isTaskHistoryLoading}
>
{$t.common?.refresh || "Refresh"}
</button>
</div>
{#if isTaskHistoryLoading}
<div class="space-y-2">
{#each Array(4) as _}
<div class="h-10 animate-pulse rounded bg-slate-100"></div>
{/each}
</div>
{:else if taskHistoryError}
<div class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{taskHistoryError}
</div>
{:else if taskHistory.length === 0}
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-6 text-center text-sm text-slate-500">
{$t.tasks?.select_task || "No backup/LLM tasks yet"}
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.common?.type || "Type"}</th>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.common?.status || "Status"}</th>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.tasks?.result || "Check"}</th>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.common?.started || "Started"}</th>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.common?.finished || "Finished"}</th>
<th class="px-3 py-2 text-left font-semibold text-slate-600">{$t.common?.actions || "Actions"}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each taskHistory as task}
{@const validation = getValidationStatus(task)}
<tr>
<td class="px-3 py-2 text-slate-800">{toTaskTypeLabel(task.plugin_id)}</td>
<td class="px-3 py-2">
<span class={`rounded-full px-2 py-1 text-xs font-semibold uppercase ${getTaskStatusClasses(task.status)}`}>
{task.status}
</span>
</td>
<td class="px-3 py-2">
<span class={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-semibold uppercase ${getValidationStatusClasses(validation.level)}`}>
{#if validation.icon}
<span class="inline-flex min-w-[18px] items-center justify-center rounded-full bg-white/70 px-1 text-[10px] font-bold">
{validation.icon}
</span>
{/if}
{validation.label}
</span>
</td>
<td class="px-3 py-2 text-slate-700">{formatDate(task.started_at)}</td>
<td class="px-3 py-2 text-slate-700">{formatDate(task.finished_at)}</td>
<td class="px-3 py-2">
<div class="flex flex-wrap items-center gap-1">
{#if task.plugin_id === "llm_dashboard_validation"}
<button
class="inline-flex items-center gap-1 rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-xs text-indigo-700 hover:bg-indigo-100"
on:click={() => openLlmReport(task.id)}
>
{$t.tasks?.open_llm_report || "LLM report"}
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if showGitManager && gitDashboardRef}
<GitManager
dashboardId={gitDashboardRef}
envId={envId || null}
dashboardTitle={dashboard?.title || ""}
bind:show={showGitManager}
/>
{/if}
<!-- [/DEF:DashboardDetail:Page] -->