feat(maintenance): add dashboard preview and expandable event list

- Add POST /api/maintenance/preview-dboards endpoint for table-to-dashboard preview
- Extend GET /api/maintenance/events with per-event dashboard list (id+title)
- Add 'Show affected dashboards' button to StartMaintenanceForm
- Add expandable row to MaintenanceEventsTable (click count to see names)
- Resolve dashboard titles via SupersetClient.get_dashboards() lookup map
- Add i18n keys for preview feature (en/ru)
This commit is contained in:
2026-06-18 09:02:24 +03:00
parent 28ba0250ba
commit 3c620dfc57
7 changed files with 338 additions and 13 deletions

View File

@@ -40,6 +40,9 @@ from ._router import router
from ._schemas import ( from ._schemas import (
MaintenanceAlreadyActiveResponse, MaintenanceAlreadyActiveResponse,
MaintenanceDashboardBannerState, MaintenanceDashboardBannerState,
MaintenanceDashboardInfo,
MaintenanceDashboardPreviewRequest,
MaintenanceDashboardPreviewResponse,
MaintenanceEndResponse, MaintenanceEndResponse,
MaintenanceEventItem, MaintenanceEventItem,
MaintenanceEventListResponse, MaintenanceEventListResponse,
@@ -119,7 +122,7 @@ async def list_dashboard_banners(
# #region list_events [C:2] [TYPE Function] # #region list_events [C:2] [TYPE Function]
# @ingroup Api # @ingroup Api
# @BRIEF Get active and completed maintenance event lists with affected dashboard counts. # @BRIEF Get active and completed maintenance event lists with affected dashboard counts and names.
# @RELATION DEPENDS_ON -> [EXT:code:has_permission_maintenance_READ] # @RELATION DEPENDS_ON -> [EXT:code:has_permission_maintenance_READ]
@router.get("/events", response_model=MaintenanceEventListResponse) @router.get("/events", response_model=MaintenanceEventListResponse)
async def list_events( async def list_events(
@@ -189,8 +192,39 @@ async def list_events(
.all() .all()
) )
# ── Build dashboard title lookup from Superset ──
dashboard_title_map: dict[int, str] = {}
try:
from ....dependencies import get_config_manager as _gcm
config_manager = _gcm()
# Collect unique environment IDs from all events
env_ids = {e.environment_id for e in active_events + completed_events if e.environment_id}
for env_id in env_ids:
env = next((e for e in config_manager.get_environments() if e.id == env_id), None)
if env:
try:
from ....core.superset_client import SupersetClient
client = SupersetClient(env)
_, dashboards = await client.get_dashboards()
for d in dashboards:
did = d.get("id")
title = d.get("dashboard_title") or d.get("title") or str(did)
if did is not None:
dashboard_title_map[int(did)] = title
except Exception as e:
app_logger.explore(
"Failed to fetch dashboards for title lookup",
extra={"env_id": env_id},
error=str(e),
)
except Exception as e:
app_logger.explore(
"Failed to build dashboard title map",
error=str(e),
)
def _to_item(event: MaintenanceEvent) -> MaintenanceEventItem: def _to_item(event: MaintenanceEvent) -> MaintenanceEventItem:
affected_count = ( states = (
db.query(MaintenanceDashboardState) db.query(MaintenanceDashboardState)
.filter( .filter(
MaintenanceDashboardState.event_id == event.id, MaintenanceDashboardState.event_id == event.id,
@@ -199,8 +233,16 @@ async def list_events(
MaintenanceDashboardStateStatus.PENDING_APPLY, MaintenanceDashboardStateStatus.PENDING_APPLY,
]), ]),
) )
.count() .all()
) )
affected_count = len(states)
dashboards = [
MaintenanceDashboardInfo(
id=state.dashboard_id,
title=dashboard_title_map.get(state.dashboard_id, str(state.dashboard_id)),
)
for state in states
]
return MaintenanceEventItem( return MaintenanceEventItem(
id=event.id, id=event.id,
tables=list(event.tables) if event.tables else [], tables=list(event.tables) if event.tables else [],
@@ -209,6 +251,7 @@ async def list_events(
message=event.message or None, message=event.message or None,
status=event.status.value, status=event.status.value,
affected_count=affected_count, affected_count=affected_count,
dashboards=dashboards,
created_at=event.created_at.isoformat() if event.created_at else "", created_at=event.created_at.isoformat() if event.created_at else "",
) )
@@ -219,6 +262,88 @@ async def list_events(
# #endregion list_events # #endregion list_events
# ── POST /api/maintenance/preview-dashboards ────────────────
# FR-015: operator, admin (NOT viewer)
# #region preview_dashboards [C:3] [TYPE Function]
# @ingroup Api
# @BRIEF Preview which dashboards would be affected by a maintenance event for given tables.
# Calls find_affected_dashboards then resolves titles from Superset.
# @RELATION DEPENDS_ON -> [EXT:code:has_permission_maintenance_READ]
# @RELATION DEPENDS_ON -> [find_affected_dashboards]
@router.post("/preview-dashboards", response_model=MaintenanceDashboardPreviewResponse)
async def preview_dashboards(
request: MaintenanceDashboardPreviewRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("maintenance", "READ")),
):
with belief_scope("preview_dashboards"):
# Resolve environment
from ....dependencies import get_config_manager as _gcm
config_manager = _gcm()
env = next(
(e for e in config_manager.get_environments() if e.id == request.environment_id),
None,
)
if not env:
raise HTTPException(status_code=404, detail=f"Environment {request.environment_id} not found")
from ....core.superset_client import SupersetClient
from ....services.maintenance._dashboard_scanner import find_affected_dashboards
client = SupersetClient(env)
# Load settings for scope/excluded/forced filtering
settings = db.query(MaintenanceSettings).filter(
MaintenanceSettings.id == "default"
).first()
# Find affected dashboard IDs
try:
dashboard_ids = await find_affected_dashboards(
request.tables,
client,
settings,
)
except Exception as e:
app_logger.explore(
"Preview failed",
extra={"tables": request.tables},
error=str(e),
)
raise HTTPException(status_code=502, detail=f"Failed to query Superset: {e}")
# Build title lookup
title_map: dict[int, str] = {}
try:
_, all_dashboards = await client.get_dashboards()
for d in all_dashboards:
did = d.get("id")
title = d.get("dashboard_title") or d.get("title") or str(did)
if did is not None:
title_map[int(did)] = title
except Exception as e:
app_logger.explore(
"Failed to fetch dashboards for title resolution",
error=str(e),
)
result = [
MaintenanceDashboardInfo(
id=did,
title=title_map.get(did, str(did)),
)
for did in dashboard_ids
]
app_logger.reflect(
"Preview completed",
extra={"tables": request.tables, "count": len(result)},
)
return MaintenanceDashboardPreviewResponse(dashboards=result)
# #endregion preview_dashboards
# ── POST /api/maintenance/start ────────────────────────────── # ── POST /api/maintenance/start ──────────────────────────────
# FR-001: Always returns 202 {task_id} after validation # FR-001: Always returns 202 {task_id} after validation
# FR-015: operator, admin (NOT viewer) # FR-015: operator, admin (NOT viewer)

View File

@@ -22,6 +22,14 @@ class MaintenanceStartRequest(BaseModel):
# #endregion MaintenanceStartRequest # #endregion MaintenanceStartRequest
# #region MaintenanceDashboardPreviewRequest [C:1] [TYPE Class]
# @BRIEF POST /api/maintenance/preview-dashboards request body.
class MaintenanceDashboardPreviewRequest(BaseModel):
tables: list[str] = Field(..., min_length=1, max_length=100)
environment_id: str = Field(..., description="Target environment for the preview")
# #endregion MaintenanceDashboardPreviewRequest
# #region MaintenanceSettingsUpdate [C:1] [TYPE Class] # #region MaintenanceSettingsUpdate [C:1] [TYPE Class]
# @BRIEF PUT /api/maintenance/settings request body — all fields optional for partial update. # @BRIEF PUT /api/maintenance/settings request body — all fields optional for partial update.
class MaintenanceSettingsUpdate(BaseModel): class MaintenanceSettingsUpdate(BaseModel):
@@ -114,6 +122,14 @@ class MaintenanceSettingsResponse(BaseModel):
# #endregion MaintenanceSettingsResponse # #endregion MaintenanceSettingsResponse
# #region MaintenanceDashboardInfo [C:1] [TYPE Class]
# @BRIEF Dashboard ID + title pair for event detail and preview responses.
class MaintenanceDashboardInfo(BaseModel):
id: int
title: str
# #endregion MaintenanceDashboardInfo
# #region MaintenanceEventItem [C:1] [TYPE Class] # #region MaintenanceEventItem [C:1] [TYPE Class]
# @BRIEF Single maintenance event summary for GET /api/maintenance/events. # @BRIEF Single maintenance event summary for GET /api/maintenance/events.
class MaintenanceEventItem(BaseModel): class MaintenanceEventItem(BaseModel):
@@ -124,6 +140,7 @@ class MaintenanceEventItem(BaseModel):
message: str | None = None message: str | None = None
status: str status: str
affected_count: int affected_count: int
dashboards: list[MaintenanceDashboardInfo] = []
created_at: str created_at: str
# #endregion MaintenanceEventItem # #endregion MaintenanceEventItem
@@ -154,4 +171,11 @@ class MaintenanceAlreadyActiveResponse(BaseModel):
status: str = "already_active" status: str = "already_active"
# #endregion MaintenanceAlreadyActiveResponse # #endregion MaintenanceAlreadyActiveResponse
# #region MaintenanceDashboardPreviewResponse [C:1] [TYPE Class]
# @BRIEF Response for POST /api/maintenance/preview-dashboards.
class MaintenanceDashboardPreviewResponse(BaseModel):
dashboards: list[MaintenanceDashboardInfo]
# #endregion MaintenanceDashboardPreviewResponse
# #endregion MaintenanceSchemasModule # #endregion MaintenanceSchemasModule

View File

@@ -80,4 +80,14 @@ export async function listDashboardBanners<T = unknown>(): Promise<T> {
return requestApi<T>(`${BASE}/dashboard-banners`, 'GET'); return requestApi<T>(`${BASE}/dashboard-banners`, 'GET');
} }
// #endregion listDashboardBanners // #endregion listDashboardBanners
// #region previewDashboards [C:2] [TYPE Function] [SEMANTICS maintenance, preview, dashboards]
// @BRIEF Preview which dashboards would be affected by maintenance for given tables.
// @PRE params contains { tables: string[], environment_id: string }.
// @POST Returns { dashboards: [{ id, title }] }.
// @RELATION DEPENDS_ON -> [requestApi]
export async function previewDashboards<T = unknown>(params: Record<string, unknown>): Promise<T> {
return requestApi<T>(`${BASE}/preview-dashboards`, 'POST', params);
}
// #endregion previewDashboards
// #endregion MaintenanceApi // #endregion MaintenanceApi

View File

@@ -1,6 +1,6 @@
<!-- #region MaintenanceEventsTable [C:3] [TYPE Component] [SEMANTICS maintenance, table, events, active, completed, remove] --> <!-- #region MaintenanceEventsTable [C:3] [TYPE Component] [SEMANTICS maintenance, table, events, active, completed, remove, expandable] -->
<!-- @ingroup Components --> <!-- @ingroup Components -->
<!-- @BRIEF Active/completed maintenance events table with removal actions. Uses inline table, skeleton rows, empty state, and status badges. --> <!-- @BRIEF Active/completed maintenance events table with removal actions and expandable dashboard list per event. -->
<!-- @LAYER Component --> <!-- @LAYER Component -->
<!-- @RELATION DEPENDS_ON -> [MaintenanceStore] --> <!-- @RELATION DEPENDS_ON -> [MaintenanceStore] -->
<!-- @RELATION DEPENDS_ON -> [MaintenanceApi] --> <!-- @RELATION DEPENDS_ON -> [MaintenanceApi] -->
@@ -10,9 +10,10 @@
<!-- @UX_STATE empty -> No active events: dashed border placeholder. --> <!-- @UX_STATE empty -> No active events: dashed border placeholder. -->
<!-- @UX_STATE loading -> Skeleton rows displayed. --> <!-- @UX_STATE loading -> Skeleton rows displayed. -->
<!-- @UX_STATE error -> Toast shown on removal failure. --> <!-- @UX_STATE error -> Toast shown on removal failure. -->
<!-- @UX_STATE expanded -> Dashboard list visible in sub-row for clicked event. -->
<!-- @UX_FEEDBACK Toast success/error on removal. --> <!-- @UX_FEEDBACK Toast success/error on removal. -->
<!-- @UX_RECOVERY Retry via individual or bulk remove. --> <!-- @UX_RECOVERY Retry via individual or bulk remove. -->
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(removingEventId, isRemovingAll) --> <!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(removingEventId, isRemovingAll, expandedEventIds) -->
<script lang="ts"> <script lang="ts">
import { maintenanceStore } from "$lib/stores/maintenance.svelte.js"; import { maintenanceStore } from "$lib/stores/maintenance.svelte.js";
import Button from "$lib/ui/Button.svelte"; import Button from "$lib/ui/Button.svelte";
@@ -25,6 +26,19 @@
let showRemoveAllConfirm = $state(false); let showRemoveAllConfirm = $state(false);
let removeTarget = $state(null); let removeTarget = $state(null);
// ── Expandable rows ─────────────────────────────────────────
let expandedEventIds = $state<Set<string>>(new Set());
function toggleExpanded(eventId: string) {
const next = new Set(expandedEventIds);
if (next.has(eventId)) {
next.delete(eventId);
} else {
next.add(eventId);
}
expandedEventIds = next;
}
let isLoading = $derived(maintenanceStore.isLoading); let isLoading = $derived(maintenanceStore.isLoading);
let activeEvents = $derived(maintenanceStore.activeEvents); let activeEvents = $derived(maintenanceStore.activeEvents);
let completedEvents = $derived(maintenanceStore.completedEvents); let completedEvents = $derived(maintenanceStore.completedEvents);
@@ -207,8 +221,24 @@
<td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={(event.tables || []).join(", ")}> <td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={(event.tables || []).join(", ")}>
{(event.tables || []).join(", ") || "-"} {(event.tables || []).join(", ") || "-"}
</td> </td>
<td class="px-4 py-3 text-sm text-text"> <td class="px-4 py-3 text-sm">
{event.affected_count ?? "-"} {#if event.dashboards && event.dashboards.length > 0}
<button
type="button"
onclick={() => toggleExpanded(event.id)}
class="inline-flex items-center gap-1 text-text hover:text-primary transition-colors cursor-pointer"
>
<svg
class="w-3.5 h-3.5 transition-transform {expandedEventIds.has(event.id) ? 'rotate-90' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="font-medium">{event.affected_count ?? "-"}</span>
</button>
{:else}
<span class="text-text">{event.affected_count ?? "-"}</span>
{/if}
</td> </td>
<td class="px-4 py-3 text-sm text-text-muted"> <td class="px-4 py-3 text-sm text-text-muted">
{formatTime(event.start_time)} {formatTime(event.start_time)}
@@ -233,6 +263,26 @@
</Button> </Button>
</td> </td>
</tr> </tr>
{#if expandedEventIds.has(event.id) && event.dashboards && event.dashboards.length > 0}
<tr>
<td colspan="7" class="px-4 py-2 bg-surface-muted">
<div class="pl-6">
<p class="text-xs font-medium text-text-muted mb-1">
{$t.maintenance?.affected_dashboards_list || "Affected dashboards"}:
</p>
<ul class="space-y-0.5">
{#each event.dashboards as dash (dash.id)}
<li class="text-xs text-text flex items-center gap-1.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-warning-DEFAULT flex-shrink-0"></span>
<span class="truncate">{dash.title}</span>
<span class="text-text-subtle flex-shrink-0">#{dash.id}</span>
</li>
{/each}
</ul>
</div>
</td>
</tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>

View File

@@ -1,6 +1,6 @@
<!-- #region StartMaintenanceForm [C:3] [TYPE Component] [SEMANTICS maintenance, start, form, template, presets] --> <!-- #region StartMaintenanceForm [C:3] [TYPE Component] [SEMANTICS maintenance, start, form, template, presets, preview] -->
<!-- @ingroup Components --> <!-- @ingroup Components -->
<!-- @BRIEF Start maintenance form with template presets, table name input, and datetime pickers. --> <!-- @BRIEF Start maintenance form with template presets, table name input, datetime pickers, and dashboard preview. -->
<!-- @LAYER Component --> <!-- @LAYER Component -->
<!-- @RELATION DEPENDS_ON -> [MaintenanceApi] --> <!-- @RELATION DEPENDS_ON -> [MaintenanceApi] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:addToast] --> <!-- @RELATION DEPENDS_ON -> [EXT:frontend:addToast] -->
@@ -8,11 +8,14 @@
<!-- @UX_STATE submitting -> Submit button shows spinner, fields disabled. --> <!-- @UX_STATE submitting -> Submit button shows spinner, fields disabled. -->
<!-- @UX_STATE success -> Toast shown, form resets. --> <!-- @UX_STATE success -> Toast shown, form resets. -->
<!-- @UX_STATE error -> Error toast with message. --> <!-- @UX_STATE error -> Error toast with message. -->
<!-- @UX_STATE preview_loading -> Preview button shows spinner, results hidden. -->
<!-- @UX_STATE preview_loaded -> Dashboard list shown below table input. -->
<!-- @UX_STATE preview_empty -> "No dashboards found" message shown. -->
<!-- @UX_FEEDBACK Toast on start/success/error. --> <!-- @UX_FEEDBACK Toast on start/success/error. -->
<!-- @UX_RECOVERY Retry via re-submit. --> <!-- @UX_RECOVERY Retry via re-submit. -->
<script lang="ts"> <script lang="ts">
import { SvelteDate } from "svelte/reactivity"; import { SvelteDate } from "svelte/reactivity";
import { startMaintenance } from "$lib/api/maintenance.js"; import { startMaintenance, previewDashboards } from "$lib/api/maintenance.js";
import { addToast } from "$lib/toasts.svelte.js"; import { addToast } from "$lib/toasts.svelte.js";
import { t } from "$lib/i18n/index.svelte.js"; import { t } from "$lib/i18n/index.svelte.js";
import HelpTooltip from "$lib/ui/HelpTooltip.svelte"; import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
@@ -69,6 +72,12 @@ import { SvelteDate } from "svelte/reactivity";
// Validation // Validation
let tableError = $state(""); let tableError = $state("");
// ── Preview state ──────────────────────────────────────────
let previewLoading = $state(false);
let previewDashboardsList = $state<Array<{ id: number; title: string }>>([]);
let previewError = $state("");
let previewShown = $state(false);
// Initialize startTime to current datetime-local // Initialize startTime to current datetime-local
function initStartTime() { function initStartTime() {
const now = new SvelteDate(); const now = new SvelteDate();
@@ -94,6 +103,39 @@ import { SvelteDate } from "svelte/reactivity";
} }
} }
// ── Preview dashboards ────────────────────────────────────
async function handlePreview() {
const trimmedTable = tableName.trim();
if (!trimmedTable) {
tableError = $t.maintenance?.table_required || "Table name is required";
return;
}
tableError = "";
const envId = environmentContextStore.value?.selectedEnvId;
if (!envId) {
addToast($t.maintenance?.no_environment || "No environment selected.", "error");
return;
}
previewLoading = true;
previewError = "";
previewShown = true;
previewDashboardsList = [];
try {
const result = await previewDashboards({
tables: [trimmedTable],
environment_id: envId,
});
previewDashboardsList = result?.dashboards || [];
} catch (e) {
previewError = e?.message || $t.maintenance?.preview_error || "Failed to preview dashboards";
} finally {
previewLoading = false;
}
}
// ── Submit ───────────────────────────────────────────────── // ── Submit ─────────────────────────────────────────────────
async function handleSubmit() { async function handleSubmit() {
// Validate // Validate
@@ -137,6 +179,9 @@ import { SvelteDate } from "svelte/reactivity";
endTime = ""; endTime = "";
message = ""; message = "";
selectedTemplate = "custom"; selectedTemplate = "custom";
previewShown = false;
previewDashboardsList = [];
previewError = "";
initStartTime(); initStartTime();
onSuccess(); onSuccess();
} catch (e) { } catch (e) {
@@ -209,6 +254,65 @@ import { SvelteDate } from "svelte/reactivity";
{#if tableError} {#if tableError}
<p class="mt-1 text-xs text-destructive">{tableError}</p> <p class="mt-1 text-xs text-destructive">{tableError}</p>
{/if} {/if}
<!-- Preview Button -->
<div class="mt-2">
<button
type="button"
onclick={handlePreview}
disabled={previewLoading || !tableName.trim()}
class="inline-flex items-center gap-1.5 rounded-md border border-border-strong bg-surface-card px-3 py-1.5 text-xs font-medium text-text
transition-colors hover:bg-surface-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if previewLoading}
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{$t.maintenance?.loading_dashboards || "Loading..."}
{:else}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{$t.maintenance?.show_dashboards || "Show affected dashboards"}
{/if}
</button>
</div>
<!-- Preview Results -->
{#if previewShown}
<div class="mt-2 rounded-md border border-border bg-surface-muted px-3 py-2">
{#if previewLoading}
<div class="flex items-center gap-2 text-xs text-text-muted">
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{$t.maintenance?.loading_dashboards || "Loading dashboards..."}
</div>
{:else if previewError}
<p class="text-xs text-destructive">{previewError}</p>
{:else if previewDashboardsList.length === 0}
<p class="text-xs text-text-muted">
{$t.maintenance?.no_dashboards_found || "No dashboards found for this table."}
</p>
{:else}
<p class="text-xs font-medium text-text mb-1.5">
{$t.maintenance?.affected_dashboards_list || "Affected dashboards"} ({previewDashboardsList.length}):
</p>
<ul class="space-y-0.5">
{#each previewDashboardsList as dash (dash.id)}
<li class="text-xs text-text-muted flex items-center gap-1.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-warning-DEFAULT flex-shrink-0"></span>
<span class="truncate">{dash.title}</span>
<span class="text-text-subtle flex-shrink-0">#{dash.id}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div> </div>
<!-- Start / End Time --> <!-- Start / End Time -->

View File

@@ -86,5 +86,11 @@
"banner_dark": "Dark theme", "banner_dark": "Dark theme",
"banner_dark_desc": "Red/dark banner for urgent alerts", "banner_dark_desc": "Red/dark banner for urgent alerts",
"banner_time_only": "Time only", "banner_time_only": "Time only",
"banner_time_only_desc": "Just start and end time" "banner_time_only_desc": "Just start and end time",
"show_dashboards": "Show affected dashboards",
"loading_dashboards": "Loading dashboards...",
"no_dashboards_found": "No dashboards found for this table.",
"affected_dashboards_list": "Affected dashboards",
"preview_error": "Failed to preview dashboards"
} }

View File

@@ -86,5 +86,11 @@
"banner_dark": "Тёмная тема", "banner_dark": "Тёмная тема",
"banner_dark_desc": "Красный баннер для срочных уведомлений", "banner_dark_desc": "Красный баннер для срочных уведомлений",
"banner_time_only": "Только время", "banner_time_only": "Только время",
"banner_time_only_desc": "Только время начала и окончания" "banner_time_only_desc": "Только время начала и окончания",
"show_dashboards": "Показать затронутые дашборды",
"loading_dashboards": "Загрузка дашбордов...",
"no_dashboards_found": "Дашборды для этой таблицы не найдены.",
"affected_dashboards_list": "Затронутые дашборды",
"preview_error": "Не удалось загрузить предпросмотр дашбордов"
} }