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:
@@ -40,6 +40,9 @@ from ._router import router
|
||||
from ._schemas import (
|
||||
MaintenanceAlreadyActiveResponse,
|
||||
MaintenanceDashboardBannerState,
|
||||
MaintenanceDashboardInfo,
|
||||
MaintenanceDashboardPreviewRequest,
|
||||
MaintenanceDashboardPreviewResponse,
|
||||
MaintenanceEndResponse,
|
||||
MaintenanceEventItem,
|
||||
MaintenanceEventListResponse,
|
||||
@@ -119,7 +122,7 @@ async def list_dashboard_banners(
|
||||
|
||||
# #region list_events [C:2] [TYPE Function]
|
||||
# @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]
|
||||
@router.get("/events", response_model=MaintenanceEventListResponse)
|
||||
async def list_events(
|
||||
@@ -189,8 +192,39 @@ async def list_events(
|
||||
.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:
|
||||
affected_count = (
|
||||
states = (
|
||||
db.query(MaintenanceDashboardState)
|
||||
.filter(
|
||||
MaintenanceDashboardState.event_id == event.id,
|
||||
@@ -199,8 +233,16 @@ async def list_events(
|
||||
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(
|
||||
id=event.id,
|
||||
tables=list(event.tables) if event.tables else [],
|
||||
@@ -209,6 +251,7 @@ async def list_events(
|
||||
message=event.message or None,
|
||||
status=event.status.value,
|
||||
affected_count=affected_count,
|
||||
dashboards=dashboards,
|
||||
created_at=event.created_at.isoformat() if event.created_at else "",
|
||||
)
|
||||
|
||||
@@ -219,6 +262,88 @@ async def 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 ──────────────────────────────
|
||||
# FR-001: Always returns 202 {task_id} after validation
|
||||
# FR-015: operator, admin (NOT viewer)
|
||||
|
||||
@@ -22,6 +22,14 @@ class MaintenanceStartRequest(BaseModel):
|
||||
# #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]
|
||||
# @BRIEF PUT /api/maintenance/settings request body — all fields optional for partial update.
|
||||
class MaintenanceSettingsUpdate(BaseModel):
|
||||
@@ -114,6 +122,14 @@ class MaintenanceSettingsResponse(BaseModel):
|
||||
# #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]
|
||||
# @BRIEF Single maintenance event summary for GET /api/maintenance/events.
|
||||
class MaintenanceEventItem(BaseModel):
|
||||
@@ -124,6 +140,7 @@ class MaintenanceEventItem(BaseModel):
|
||||
message: str | None = None
|
||||
status: str
|
||||
affected_count: int
|
||||
dashboards: list[MaintenanceDashboardInfo] = []
|
||||
created_at: str
|
||||
# #endregion MaintenanceEventItem
|
||||
|
||||
@@ -154,4 +171,11 @@ class MaintenanceAlreadyActiveResponse(BaseModel):
|
||||
status: str = "already_active"
|
||||
# #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
|
||||
|
||||
@@ -80,4 +80,14 @@ export async function listDashboardBanners<T = unknown>(): Promise<T> {
|
||||
return requestApi<T>(`${BASE}/dashboard-banners`, 'GET');
|
||||
}
|
||||
// #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
|
||||
|
||||
@@ -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 -->
|
||||
<!-- @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 -->
|
||||
<!-- @RELATION DEPENDS_ON -> [MaintenanceStore] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [MaintenanceApi] -->
|
||||
@@ -10,9 +10,10 @@
|
||||
<!-- @UX_STATE empty -> No active events: dashed border placeholder. -->
|
||||
<!-- @UX_STATE loading -> Skeleton rows displayed. -->
|
||||
<!-- @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_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">
|
||||
import { maintenanceStore } from "$lib/stores/maintenance.svelte.js";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
@@ -25,6 +26,19 @@
|
||||
let showRemoveAllConfirm = $state(false);
|
||||
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 activeEvents = $derived(maintenanceStore.activeEvents);
|
||||
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(", ")}>
|
||||
{(event.tables || []).join(", ") || "-"}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-text">
|
||||
{event.affected_count ?? "-"}
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{#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 class="px-4 py-3 text-sm text-text-muted">
|
||||
{formatTime(event.start_time)}
|
||||
@@ -233,6 +263,26 @@
|
||||
</Button>
|
||||
</td>
|
||||
</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}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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 -->
|
||||
<!-- @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 -->
|
||||
<!-- @RELATION DEPENDS_ON -> [MaintenanceApi] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:addToast] -->
|
||||
@@ -8,11 +8,14 @@
|
||||
<!-- @UX_STATE submitting -> Submit button shows spinner, fields disabled. -->
|
||||
<!-- @UX_STATE success -> Toast shown, form resets. -->
|
||||
<!-- @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_RECOVERY Retry via re-submit. -->
|
||||
<script lang="ts">
|
||||
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 { t } from "$lib/i18n/index.svelte.js";
|
||||
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
|
||||
@@ -69,6 +72,12 @@ import { SvelteDate } from "svelte/reactivity";
|
||||
// Validation
|
||||
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
|
||||
function initStartTime() {
|
||||
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 ─────────────────────────────────────────────────
|
||||
async function handleSubmit() {
|
||||
// Validate
|
||||
@@ -137,6 +179,9 @@ import { SvelteDate } from "svelte/reactivity";
|
||||
endTime = "";
|
||||
message = "";
|
||||
selectedTemplate = "custom";
|
||||
previewShown = false;
|
||||
previewDashboardsList = [];
|
||||
previewError = "";
|
||||
initStartTime();
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
@@ -209,6 +254,65 @@ import { SvelteDate } from "svelte/reactivity";
|
||||
{#if tableError}
|
||||
<p class="mt-1 text-xs text-destructive">{tableError}</p>
|
||||
{/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>
|
||||
|
||||
<!-- Start / End Time -->
|
||||
|
||||
@@ -86,5 +86,11 @@
|
||||
"banner_dark": "Dark theme",
|
||||
"banner_dark_desc": "Red/dark banner for urgent alerts",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -86,5 +86,11 @@
|
||||
"banner_dark": "Тёмная тема",
|
||||
"banner_dark_desc": "Красный баннер для срочных уведомлений",
|
||||
"banner_time_only": "Только время",
|
||||
"banner_time_only_desc": "Только время начала и окончания"
|
||||
"banner_time_only_desc": "Только время начала и окончания",
|
||||
|
||||
"show_dashboards": "Показать затронутые дашборды",
|
||||
"loading_dashboards": "Загрузка дашбордов...",
|
||||
"no_dashboards_found": "Дашборды для этой таблицы не найдены.",
|
||||
"affected_dashboards_list": "Затронутые дашборды",
|
||||
"preview_error": "Не удалось загрузить предпросмотр дашбордов"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user