feat(sidebar): redesign with sections, new navigation items, profile footer
- Split sidebar into 3 groups: Resources / Operations / System - Added Migration section (Overview + Mappings) - was hidden in routes - Added Git section (Repository Status) - was hidden in routes - Added Tools section (Mapper, Debug, Storage, Backups) - was hidden in routes - Moved Settings gear icon to footer (quick link to /settings) - Moved Profile to footer (avatar + username) - Moved Collapse button to header (always visible) - Organized Admin with all sub-items (Users, Roles, ADFS, LLM) - Reduced visual noise: section labels collapse independently - Kept existing features: RBAC filtering, health badges, mobile overlay, collapse animation - Updated tests to match new structure - Updated i18n (en/ru) with new keys
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- #region Sidebar [C:4] [TYPE Component] [SEMANTICS sidebar, navigation, categories, toggle, resource] -->
|
||||
<!-- @BRIEF Persistent left sidebar with resource categories navigation, expand/collapse, health indicators, and RBAC filtering. -->
|
||||
<!-- #region Sidebar [C:4] [TYPE Component] [SEMANTICS sidebar, navigation, sections, toggle, resource] -->
|
||||
<!-- @BRIEF Persistent left sidebar with grouped sections (Resources/Operations/System), expand/collapse, health indicators, RBAC filtering, profile footer. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION BINDS_TO -> [sidebarStore] -->
|
||||
<!-- @RELATION BINDS_TO -> [healthStore] -->
|
||||
@@ -8,47 +8,11 @@
|
||||
<!-- @UX_STATE Idle -> Sidebar visible with current state. -->
|
||||
<!-- @UX_STATE Toggling -> Animation plays for 200ms on expand/collapse. -->
|
||||
<!-- @UX_STATE MobileOpen -> Overlay and sidebar visible until dismissed. -->
|
||||
<!-- @UX_FEEDBACK Active item highlighted with different background. -->
|
||||
<!-- @UX_FEEDBACK Active item highlighted with different background; section labels visible when expanded. -->
|
||||
<!-- @UX_FEEDBACK Badge count shown on failing dashboards. -->
|
||||
<!-- @UX_FEEDBACK Profile footer shows user avatar and username. -->
|
||||
<!-- @UX_RECOVERY Click outside on mobile closes overlay. -->
|
||||
<script>
|
||||
/**
|
||||
* @PURPOSE: Persistent left sidebar with resource categories navigation
|
||||
* @LAYER: UI
|
||||
* @RELATION: BINDS_TO -> sidebar
|
||||
* @RELATION: BINDS_TO -> auth
|
||||
* @RELATION: BINDS_TO -> health_store
|
||||
* @RELATION: DEPENDS_ON -> SidebarNavigation
|
||||
* @RELATION: DEPENDS_ON -> Icon
|
||||
* @SEMANTICS: Navigation
|
||||
* @PRE: Sidebar store and translated category definitions are available before navigation is rendered.
|
||||
* @POST: Active category and item remain aligned with the current route and mobile state is dismissible.
|
||||
* @SIDE_EFFECT: Persists sidebar state via store actions, refreshes health indicators, and updates window location on navigation.
|
||||
* @DATA_CONTRACT: SidebarCategory[] -> RenderedNavigationTree
|
||||
* @INVARIANT: Always shows active category and item
|
||||
* @UX_REACTIVITY: Uses $derived for store-backed navigation state and $effect guards for route/category coherence.
|
||||
*
|
||||
* @UX_STATE: Idle -> Sidebar visible with current state
|
||||
* @UX_STATE: Toggling -> Animation plays for 200ms
|
||||
* @UX_STATE: MobileOpen -> Overlay and sidebar stay visible on small screens until dismissed.
|
||||
* @UX_FEEDBACK: Active item highlighted with different background
|
||||
* @UX_RECOVERY: Click outside on mobile closes overlay
|
||||
*
|
||||
* @TEST_CONTRACT Component_Sidebar ->
|
||||
* {
|
||||
* required_props: {},
|
||||
* optional_props: {},
|
||||
* invariants: [
|
||||
* "Highlights active category and sub-item based on current page URL",
|
||||
* "Toggles sidebar via toggleSidebar store action",
|
||||
* "Closes mobile overlay on click outside"
|
||||
* ]
|
||||
* }
|
||||
* @TEST_FIXTURE idle_state -> {}
|
||||
* @TEST_EDGE mobile_open -> shows mobile overlay mask
|
||||
* @TEST_INVARIANT navigation -> verifies: [idle_state]
|
||||
*/
|
||||
|
||||
import { page } from "$app/state";
|
||||
import { fromStore } from "svelte/store";
|
||||
import {
|
||||
@@ -61,9 +25,10 @@
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { buildSidebarCategories } from "$lib/components/layout/sidebarNavigation.js";
|
||||
import { buildSidebarSections } from "$lib/components/layout/sidebarNavigation.js";
|
||||
import { fetchApi } from "$lib/api.js";
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
const sidebarState = fromStore(sidebarStore);
|
||||
@@ -74,14 +39,17 @@
|
||||
let features = $state({});
|
||||
let cleanupInterval = null;
|
||||
|
||||
let categories = $derived(
|
||||
buildSidebarCategories(
|
||||
let sections = $derived(
|
||||
buildSidebarSections(
|
||||
translationState.current,
|
||||
authState.current?.user || null,
|
||||
features,
|
||||
),
|
||||
);
|
||||
|
||||
// Flat categories list for fallback resolution
|
||||
let allCategories = $derived(sections.flatMap((s) => s.categories));
|
||||
|
||||
let isExpanded = $derived(sidebarState.current?.isExpanded ?? true);
|
||||
let activeCategory = $derived(
|
||||
sidebarState.current?.activeCategory || "dashboards",
|
||||
@@ -90,6 +58,9 @@
|
||||
let isMobileOpen = $derived(Boolean(sidebarState.current?.isMobileOpen));
|
||||
let failingDashboardCount = $derived(failingCountState.current || 0);
|
||||
let expandedCategories = $state(new Set(["dashboards"]));
|
||||
let expandedSections = $state(new Set(["resources", "operations", "system"]));
|
||||
|
||||
let user = $derived(authState.current?.user || null);
|
||||
|
||||
function withExpandedCategory(categoryId) {
|
||||
const next = new Set(expandedCategories);
|
||||
@@ -107,13 +78,23 @@
|
||||
expandedCategories = next;
|
||||
}
|
||||
|
||||
function toggleExpandedSection(sectionId) {
|
||||
const next = new Set(expandedSections);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
} else {
|
||||
next.add(sectionId);
|
||||
}
|
||||
expandedSections = next;
|
||||
}
|
||||
|
||||
// Keep active category valid after RBAC filtering.
|
||||
$effect(() => {
|
||||
if (
|
||||
categories.length > 0 &&
|
||||
!categories.some((category) => category.id === activeCategory)
|
||||
allCategories.length > 0 &&
|
||||
!allCategories.some((cat) => cat.id === activeCategory)
|
||||
) {
|
||||
const fallbackCategory = categories[0];
|
||||
const fallbackCategory = allCategories[0];
|
||||
const fallbackPath =
|
||||
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
|
||||
setActiveItem(fallbackCategory.id, fallbackPath);
|
||||
@@ -130,7 +111,7 @@
|
||||
// Update active item when page changes
|
||||
$effect(() => {
|
||||
if (page.url.pathname !== activeItem) {
|
||||
const matched = categories.find((cat) => {
|
||||
const matched = allCategories.find((cat) => {
|
||||
if (page.url.pathname.startsWith(cat.path)) return true;
|
||||
return (cat.subItems || []).some((item) =>
|
||||
page.url.pathname.startsWith(item.path),
|
||||
@@ -142,33 +123,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleItemClick(category) {
|
||||
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
|
||||
setActiveItem(category.id, category.path);
|
||||
closeMobile();
|
||||
if (browser) {
|
||||
window.location.href = category.path;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryToggle(categoryId, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!isExpanded) {
|
||||
console.log(
|
||||
`[Sidebar][Action] Expand sidebar and category ${categoryId}`,
|
||||
);
|
||||
toggleSidebar();
|
||||
withExpandedCategory(categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Sidebar][Action] Toggle category ${categoryId}`);
|
||||
toggleExpandedCategory(categoryId);
|
||||
}
|
||||
|
||||
function handleSubItemClick(categoryId, path) {
|
||||
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
|
||||
setActiveItem(categoryId, path);
|
||||
closeMobile();
|
||||
if (browser) {
|
||||
@@ -178,44 +145,58 @@
|
||||
|
||||
function handleToggleClick(event) {
|
||||
event.stopPropagation();
|
||||
console.log("[Sidebar][Action] Toggle sidebar");
|
||||
toggleSidebar();
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
console.log("[Sidebar][Action] Close mobile overlay");
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
// Close mobile overlay on route change — depends only on page.url.pathname, NOT isMobileOpen.
|
||||
// Otherwise clicking the burger (which sets isMobileOpen=true) would immediately close it.
|
||||
function handleProfileClick() {
|
||||
closeMobile();
|
||||
if (browser) {
|
||||
window.location.href = "/profile";
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsClick(e) {
|
||||
e.stopPropagation();
|
||||
closeMobile();
|
||||
if (browser) {
|
||||
window.location.href = "/settings";
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(user) {
|
||||
if (!user) return "?";
|
||||
if (user.display_name) return user.display_name.charAt(0).toUpperCase();
|
||||
if (user.username) return user.username.charAt(0).toUpperCase();
|
||||
if (user.email) return user.email.charAt(0).toUpperCase();
|
||||
return "?";
|
||||
}
|
||||
|
||||
// Close mobile overlay on route change
|
||||
$effect(() => {
|
||||
const _pathname = page.url.pathname;
|
||||
closeMobile();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Fetch feature flags first, then conditionally refresh health
|
||||
fetchApi("/settings/features")
|
||||
.then((res) => {
|
||||
features = res;
|
||||
if (res.health_monitor) {
|
||||
healthStore.refresh();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000);
|
||||
cleanupInterval = interval;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Graceful degradation: default to {} (all features visible)
|
||||
features = {};
|
||||
// Feature flags unavailable — start health polling only if it doesn't return disabled
|
||||
healthStore.refresh().then(() => {
|
||||
const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000);
|
||||
cleanupInterval = interval;
|
||||
}).catch(() => {
|
||||
// If the first refresh already failed (feature disabled), do not poll
|
||||
});
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -226,10 +207,10 @@
|
||||
|
||||
<!-- Mobile overlay (only on mobile) -->
|
||||
{#if isMobileOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-20 md:hidden"
|
||||
onclick={handleOverlayClick}
|
||||
onkeydown={(e) => e.key === "Escape" && handleOverlayClick()}
|
||||
role="presentation"
|
||||
></div>
|
||||
{/if}
|
||||
@@ -242,135 +223,209 @@
|
||||
? 'translate-x-0 w-sidebar'
|
||||
: '-translate-x-full md:translate-x-0'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<!-- ═══ HEADER: Logo + Collapse ═══ -->
|
||||
<div
|
||||
class="flex items-center border-b border-slate-200 p-4 {isExpanded
|
||||
class="flex items-center border-b border-slate-200 px-3 py-3 {isExpanded
|
||||
? 'justify-between'
|
||||
: 'justify-center'}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<span class="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-800 flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-primary-light to-blue-100 text-primary ring-1 ring-primary-ring"
|
||||
>
|
||||
<Icon name="layers" size={14} />
|
||||
<Icon name="layers" size={15} />
|
||||
</span>
|
||||
{translationState.current?.nav?.menu}
|
||||
{translationState.current?.nav?.menu || "Menu"}
|
||||
</span>
|
||||
<button
|
||||
onclick={handleToggleClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
aria-label={translationState.current?.nav?.collapse}
|
||||
title={translationState.current?.nav?.collapse}
|
||||
>
|
||||
<Icon name="chevronLeft" size={15} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">M</span>
|
||||
<span
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-primary-light to-blue-100 text-primary ring-1 ring-primary-ring"
|
||||
>
|
||||
<Icon name="layers" size={15} />
|
||||
</span>
|
||||
<button
|
||||
onclick={handleToggleClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors ml-1"
|
||||
aria-label={translationState.current?.nav?.expand}
|
||||
title={translationState.current?.nav?.expand}
|
||||
>
|
||||
<Icon name="chevronRight" size={15} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation items -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
{#each categories as category}
|
||||
<div>
|
||||
<!-- Category Header -->
|
||||
<!-- ═══ NAVIGATION: Sections with categories ═══ -->
|
||||
<nav class="flex-1 overflow-y-auto py-1">
|
||||
{#each sections as section}
|
||||
<!-- Section label (only when expanded) -->
|
||||
{#if isExpanded}
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-4 py-3 transition-colors hover:bg-slate-100
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
|
||||
: ''}"
|
||||
onclick={(e) => handleCategoryToggle(category.id, e)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleCategoryToggle(category.id, e)}
|
||||
class="flex items-center justify-between px-4 pt-4 pb-1 cursor-pointer select-none"
|
||||
onclick={() => toggleExpandedSection(section.id)}
|
||||
onkeydown={(e) => (e.key === "Enter") && toggleExpandedSection(section.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={category.label}
|
||||
aria-expanded={expandedCategories.has(category.id)}
|
||||
aria-expanded={expandedSections.has(section.id)}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
|
||||
<span class="text-[11px] font-semibold uppercase tracking-widest text-gray-400">
|
||||
{section.label}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size={12}
|
||||
className="text-gray-300 transition-transform duration-200 {expandedSections.has(section.id) ? '' : '-rotate-90'}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Section categories -->
|
||||
{#if !isExpanded || expandedSections.has(section.id)}
|
||||
{#each section.categories as category}
|
||||
<div>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-colors hover:bg-slate-100
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary'
|
||||
: 'text-gray-600'}"
|
||||
onclick={(e) => handleCategoryToggle(category.id, e)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleCategoryToggle(category.id, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={category.label}
|
||||
aria-expanded={expandedCategories.has(category.id)}
|
||||
>
|
||||
<Icon name={category.icon} size={16} strokeWidth={2} />
|
||||
{#if !isExpanded && category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<div class="flex items-center min-w-0">
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{#if category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<span
|
||||
class="ml-auto mr-2 inline-flex items-center justify-center px-2 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
|
||||
title="{failingDashboardCount} failing dashboards"
|
||||
class="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
|
||||
>
|
||||
{failingDashboardCount}
|
||||
<Icon name={category.icon} size={15} strokeWidth={2} />
|
||||
{#if !isExpanded && category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{#if category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<span
|
||||
class="ml-auto mr-1 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
|
||||
title="{failingDashboardCount} failing dashboards"
|
||||
>
|
||||
{failingDashboardCount}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size={14}
|
||||
className="shrink-0 text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sub Items (only when expanded) -->
|
||||
{#if isExpanded && expandedCategories.has(category.id)}
|
||||
<div class="overflow-hidden transition-all duration-200">
|
||||
{#each category.subItems as subItem}
|
||||
<div
|
||||
class="mx-2 flex items-center rounded-md px-2 py-1.5 pl-10 cursor-pointer transition-colors text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900
|
||||
{activeItem === subItem.path
|
||||
? 'bg-primary-light text-primary font-medium'
|
||||
: ''}"
|
||||
onclick={() => handleSubItemClick(category.id, subItem.path)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleSubItemClick(category.id, subItem.path)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{subItem.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size={16}
|
||||
className="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sub Items (only when expanded) -->
|
||||
{#if isExpanded && expandedCategories.has(category.id)}
|
||||
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
|
||||
{#each category.subItems as subItem}
|
||||
<div
|
||||
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
|
||||
{activeItem === subItem.path
|
||||
? 'bg-primary-light text-primary'
|
||||
: ''}"
|
||||
onclick={() => handleSubItemClick(category.id, subItem.path)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleSubItemClick(category.id, subItem.path)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{subItem.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Footer with Collapse button -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onclick={handleToggleClick}
|
||||
>
|
||||
<span
|
||||
class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600"
|
||||
<!-- ═══ FOOTER: Profile + Settings + Collapse ═══ -->
|
||||
<div class="border-t border-gray-200">
|
||||
{#if isExpanded}
|
||||
<!-- Expanded footer: profile info + settings gear -->
|
||||
<div class="flex items-center justify-between px-3 py-3">
|
||||
<button
|
||||
onclick={handleProfileClick}
|
||||
class="flex items-center gap-2 min-w-0 rounded-lg px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Icon name="chevronLeft" size={14} />
|
||||
</span>
|
||||
{translationState.current?.nav?.collapse}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onclick={handleToggleClick}
|
||||
aria-label={translationState.current?.nav?.expand_sidebar}
|
||||
>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
<span class="ml-2">{translationState.current?.nav?.expand}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-100 to-indigo-200 text-indigo-700 text-xs font-bold ring-1 ring-indigo-200"
|
||||
>
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
<span class="truncate max-w-[120px]">
|
||||
{user?.display_name || user?.username || user?.email || "Profile"}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={handleSettingsClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
aria-label={translationState.current?.nav?.settings}
|
||||
title={translationState.current?.nav?.settings}
|
||||
>
|
||||
<Icon name="settings" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed footer: just avatar + expand -->
|
||||
<div class="flex flex-col items-center gap-1 px-2 py-3">
|
||||
<button
|
||||
onclick={handleProfileClick}
|
||||
class="flex items-center justify-center rounded-lg px-1 py-1 hover:bg-gray-100 transition-colors"
|
||||
aria-label={translationState.current?.nav?.profile}
|
||||
title={translationState.current?.nav?.profile}
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-indigo-100 to-indigo-200 text-indigo-700 text-xs font-bold ring-1 ring-indigo-200"
|
||||
>
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleToggleClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
aria-label={translationState.current?.nav?.expand}
|
||||
title={translationState.current?.nav?.expand}
|
||||
>
|
||||
<Icon name="chevronRight" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #endregion Sidebar -->
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
// #region SidebarNavigationTest:Module [TYPE Function]
|
||||
// @SEMANTICS: tests, sidebar, navigation, rbac, permissions
|
||||
// @PURPOSE: Verifies RBAC-based sidebar category and subitem visibility.
|
||||
// @PURPOSE: Verifies RBAC-based sidebar sections and category visibility.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: DEPENDS_ON -> [SidebarNavigation]
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSidebarCategories } from "../sidebarNavigation.js";
|
||||
import { buildSidebarCategories, buildSidebarSections } from "../sidebarNavigation.js";
|
||||
|
||||
const i18nState = {
|
||||
nav: {
|
||||
section_resources: "Resources",
|
||||
section_operations: "Operations",
|
||||
section_system: "System",
|
||||
dashboards: "Dashboards",
|
||||
overview: "Overview",
|
||||
health_center: "Health Center",
|
||||
datasets: "Datasets",
|
||||
all_datasets: "All datasets",
|
||||
all_datasets: "All Datasets",
|
||||
dataset_review_workspace: "Review Workspace",
|
||||
migration: "Migration",
|
||||
migration_overview: "Overview",
|
||||
migration_mappings: "Mappings",
|
||||
git: "Git",
|
||||
git_repo_status: "Repository Status",
|
||||
translation: "Translation",
|
||||
translation_jobs: "Jobs",
|
||||
translation_dictionaries: "Dictionaries",
|
||||
translation_history: "History",
|
||||
reports: "Reports",
|
||||
all_reports: "All Reports",
|
||||
tools: "Tools",
|
||||
tools_mapper: "Dataset Mapper",
|
||||
tools_debug: "System Debug",
|
||||
tools_storage: "File Storage",
|
||||
tools_backups: "Backup Manager",
|
||||
storage: "Storage",
|
||||
backups: "Backups",
|
||||
repositories: "Repositories",
|
||||
reports: "Reports",
|
||||
profile: "Profile",
|
||||
admin: "Admin",
|
||||
admin_users: "User management",
|
||||
admin_roles: "Role management",
|
||||
settings: "Settings",
|
||||
admin: "Admin",
|
||||
admin_users: "User Management",
|
||||
admin_roles: "Role Management",
|
||||
admin_settings: "ADFS Config",
|
||||
admin_llm: "LLM Providers",
|
||||
profile: "Profile",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,6 +52,20 @@ function makeUser(roles) {
|
||||
}
|
||||
|
||||
describe("sidebarNavigation", () => {
|
||||
it("shows sections structure with correct section IDs", () => {
|
||||
// Admin user sees all sections
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Admin",
|
||||
permissions: [],
|
||||
},
|
||||
]);
|
||||
const sections = buildSidebarSections(i18nState, user);
|
||||
const sectionIds = sections.map((s) => s.id);
|
||||
|
||||
expect(sectionIds).toEqual(["resources", "operations", "system"]);
|
||||
});
|
||||
|
||||
it("shows only categories available to a non-admin user", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
@@ -44,7 +80,19 @@ describe("sidebarNavigation", () => {
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const categoryIds = categories.map((category) => category.id);
|
||||
|
||||
expect(categoryIds).toEqual(["dashboards", "datasets", "reports", "profile"]);
|
||||
// migration requires plugin:migration → visible
|
||||
// git/tools have no requiredPermission → always visible
|
||||
// translation requires translate.job → hidden
|
||||
// storage requires plugin:storage → hidden
|
||||
// admin requires admin:* → hidden
|
||||
expect(categoryIds).toEqual([
|
||||
"dashboards",
|
||||
"datasets",
|
||||
"migration",
|
||||
"git",
|
||||
"reports",
|
||||
"tools",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides admin category when user has no admin permissions", () => {
|
||||
@@ -76,11 +124,12 @@ describe("sidebarNavigation", () => {
|
||||
expect(adminCategory.subItems.map((item) => item.path)).toEqual([
|
||||
"/admin/users",
|
||||
"/admin/roles",
|
||||
"/settings",
|
||||
"/admin/settings",
|
||||
"/admin/settings/llm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps profile visible even without explicit plugin permissions", () => {
|
||||
it("shows git and tools for users without plugin permissions", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Basic",
|
||||
@@ -91,8 +140,10 @@ describe("sidebarNavigation", () => {
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const categoryIds = categories.map((category) => category.id);
|
||||
|
||||
expect(categoryIds).toEqual(["profile"]);
|
||||
// Profile is now in the sidebar footer, not a nav category
|
||||
// Git and Tools have no requiredPermission → always visible
|
||||
expect(categoryIds).toContain("git");
|
||||
expect(categoryIds).toContain("tools");
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion SidebarNavigationTest:Module
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// #region SidebarNavigation:Module [TYPE Function]
|
||||
// @SEMANTICS: navigation, sidebar, rbac, menu, filtering
|
||||
// @PURPOSE: Build sidebar navigation categories filtered by current user permissions.
|
||||
// @PURPOSE: Build sidebar navigation sections and categories filtered by user permissions and feature flags.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> [Permissions]
|
||||
// @RELATION: BINDS_TO -> [Sidebar]
|
||||
@@ -22,192 +22,184 @@ function isItemAllowed(user, item) {
|
||||
}
|
||||
// #endregion isItemAllowed:Function
|
||||
|
||||
// #region buildSidebarCategories:Function [TYPE Function]
|
||||
// @PURPOSE: Build translated sidebar categories and filter them by RBAC permissions and feature flags.
|
||||
// #region isFeatureEnabled:Function [TYPE Function]
|
||||
function isFeatureEnabled(item, features) {
|
||||
if (!item?.requiredFeature) return true;
|
||||
return features[item.requiredFeature] !== false;
|
||||
}
|
||||
// #endregion isFeatureEnabled:Function
|
||||
|
||||
// #region buildSidebarSections:Function [TYPE Function]
|
||||
// @PURPOSE: Build translated sidebar sections with categories, filtered by RBAC permissions and feature flags.
|
||||
// @PRE: i18nState provides nav labels; user can be null; features default to {} (all enabled).
|
||||
// @POST: Returns only categories/subitems available for provided user and enabled features.
|
||||
export function buildSidebarCategories(i18nState, user, features = {}) {
|
||||
// @POST: Returns only sections/categories/subitems available for provided user and enabled features.
|
||||
export function buildSidebarSections(i18nState, user, features = {}) {
|
||||
const nav = i18nState?.nav || {};
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: nav.dashboards,
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
path: "/dashboards",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.overview,
|
||||
path: "/dashboards",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.health_center,
|
||||
path: "/dashboards/health",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
requiredFeature: "health_monitor",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: nav.datasets,
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
path: "/datasets",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.all_datasets,
|
||||
path: "/datasets",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.dataset_review_workspace,
|
||||
path: "/datasets/review",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
requiredFeature: "dataset_review",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: nav.storage,
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.backups,
|
||||
path: "/storage/backups",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.repositories,
|
||||
path: "/storage/repos",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "translation",
|
||||
label: nav.translation,
|
||||
icon: "translate",
|
||||
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
|
||||
path: "/translate",
|
||||
requiredPermission: "translate.job",
|
||||
requiredAction: "VIEW",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.translation_jobs,
|
||||
path: "/translate",
|
||||
requiredPermission: "translate.job",
|
||||
requiredAction: "VIEW",
|
||||
},
|
||||
{
|
||||
label: nav.translation_dictionaries,
|
||||
path: "/translate/dictionaries",
|
||||
requiredPermission: "translate.dictionary",
|
||||
requiredAction: "VIEW",
|
||||
},
|
||||
{
|
||||
label: nav.translation_history,
|
||||
path: "/translate/history",
|
||||
requiredPermission: "translate.job",
|
||||
requiredAction: "VIEW",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: nav.reports,
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
path: "/reports",
|
||||
requiredPermission: "tasks",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.reports,
|
||||
path: "/reports",
|
||||
requiredPermission: "tasks",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: nav.profile,
|
||||
icon: "admin",
|
||||
tone: "from-indigo-100 to-indigo-200 text-indigo-700 ring-indigo-200",
|
||||
path: "/profile",
|
||||
subItems: [{ label: nav.profile, path: "/profile" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: nav.admin,
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.admin_users,
|
||||
path: "/admin/users",
|
||||
requiredPermission: "admin:users",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.admin_roles,
|
||||
path: "/admin/roles",
|
||||
requiredPermission: "admin:roles",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.settings,
|
||||
path: "/settings",
|
||||
requiredPermission: "admin:settings",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// ── Section 1: Resources ─────────────────────────────────
|
||||
const resources = {
|
||||
id: "resources",
|
||||
label: nav.section_resources,
|
||||
categories: [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: nav.dashboards,
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
path: "/dashboards",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.overview, path: "/dashboards", requiredPermission: "plugin:migration", requiredAction: "READ" },
|
||||
{ label: nav.health_center, path: "/dashboards/health", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "health_monitor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: nav.datasets,
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
path: "/datasets",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.all_datasets, path: "/datasets", requiredPermission: "plugin:migration", requiredAction: "READ" },
|
||||
{ label: nav.dataset_review_workspace, path: "/datasets/review", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "dataset_review" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "migration",
|
||||
label: nav.migration,
|
||||
icon: "layers",
|
||||
tone: "from-orange-100 to-orange-200 text-orange-700 ring-orange-200",
|
||||
path: "/migration",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.migration_overview || "Overview", path: "/migration", requiredPermission: "plugin:migration", requiredAction: "READ" },
|
||||
{ label: nav.migration_mappings || "Mappings", path: "/migration/mappings", requiredPermission: "plugin:migration", requiredAction: "READ" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function isFeatureEnabled(item) {
|
||||
if (!item?.requiredFeature) return true;
|
||||
return features[item.requiredFeature] !== false;
|
||||
}
|
||||
// ── Section 2: Operations ────────────────────────────────
|
||||
const operations = {
|
||||
id: "operations",
|
||||
label: nav.section_operations,
|
||||
categories: [
|
||||
{
|
||||
id: "git",
|
||||
label: nav.git,
|
||||
icon: "activity",
|
||||
tone: "from-purple-100 to-purple-200 text-purple-700 ring-purple-200",
|
||||
path: "/git",
|
||||
subItems: [
|
||||
{ label: nav.git_repo_status || "Repository Status", path: "/git" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "translation",
|
||||
label: nav.translation,
|
||||
icon: "translate",
|
||||
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
|
||||
path: "/translate",
|
||||
requiredPermission: "translate.job",
|
||||
requiredAction: "VIEW",
|
||||
subItems: [
|
||||
{ label: nav.translation_jobs, path: "/translate", requiredPermission: "translate.job", requiredAction: "VIEW" },
|
||||
{ label: nav.translation_dictionaries, path: "/translate/dictionaries", requiredPermission: "translate.dictionary", requiredAction: "VIEW" },
|
||||
{ label: nav.translation_history, path: "/translate/history", requiredPermission: "translate.job", requiredAction: "VIEW" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: nav.reports,
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
path: "/reports",
|
||||
requiredPermission: "tasks",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.all_reports || "All Reports", path: "/reports", requiredPermission: "tasks", requiredAction: "READ" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return categories
|
||||
.map((category) => {
|
||||
const visibleSubItems = (category.subItems || []).filter(
|
||||
(subItem) => isItemAllowed(user, subItem) && isFeatureEnabled(subItem),
|
||||
);
|
||||
return {
|
||||
...category,
|
||||
subItems: visibleSubItems,
|
||||
};
|
||||
})
|
||||
.filter((category) => {
|
||||
const categoryVisible = isItemAllowed(user, category);
|
||||
if (!categoryVisible) return false;
|
||||
// ── Section 3: System ────────────────────────────────────
|
||||
const system = {
|
||||
id: "system",
|
||||
label: nav.section_system,
|
||||
categories: [
|
||||
{
|
||||
id: "tools",
|
||||
label: nav.tools,
|
||||
icon: "list",
|
||||
tone: "from-slate-100 to-slate-200 text-slate-700 ring-slate-200",
|
||||
path: "/tools/mapper",
|
||||
subItems: [
|
||||
{ label: nav.tools_mapper, path: "/tools/mapper" },
|
||||
{ label: nav.tools_debug, path: "/tools/debug" },
|
||||
{ label: nav.tools_storage, path: "/tools/storage" },
|
||||
{ label: nav.tools_backups, path: "/tools/backups" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: nav.storage,
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.backups, path: "/storage/backups", requiredPermission: "plugin:storage", requiredAction: "READ" },
|
||||
{ label: nav.repositories, path: "/storage/repos", requiredPermission: "plugin:storage", requiredAction: "READ" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: nav.admin,
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
path: "/admin/users",
|
||||
subItems: [
|
||||
{ label: nav.admin_users, path: "/admin/users", requiredPermission: "admin:users", requiredAction: "READ" },
|
||||
{ label: nav.admin_roles, path: "/admin/roles", requiredPermission: "admin:roles", requiredAction: "READ" },
|
||||
{ label: nav.admin_settings, path: "/admin/settings", requiredPermission: "admin:settings", requiredAction: "READ" },
|
||||
{ label: nav.admin_llm, path: "/admin/settings/llm", requiredPermission: "admin:settings", requiredAction: "READ" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const hasVisibleSubItems =
|
||||
Array.isArray(category.subItems) && category.subItems.length > 0;
|
||||
return hasVisibleSubItems;
|
||||
});
|
||||
const allSections = [resources, operations, system];
|
||||
|
||||
// Filter by permissions and features
|
||||
return allSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
categories: section.categories
|
||||
.map((category) => {
|
||||
const visibleSubItems = (category.subItems || []).filter(
|
||||
(sub) => isItemAllowed(user, sub) && isFeatureEnabled(sub, features),
|
||||
);
|
||||
return { ...category, subItems: visibleSubItems };
|
||||
})
|
||||
.filter((category) => {
|
||||
if (!isItemAllowed(user, category)) return false;
|
||||
return Array.isArray(category.subItems) && category.subItems.length > 0;
|
||||
}),
|
||||
}))
|
||||
.filter((section) => section.categories.length > 0);
|
||||
}
|
||||
// #endregion buildSidebarCategories:Function
|
||||
// #endregion buildSidebarSections:Function
|
||||
|
||||
// #endregion SidebarNavigation:Module
|
||||
// ── Legacy adapter for backward compatibility ──────────────
|
||||
// @DEPRECATED Use buildSidebarSections instead. Returns flat category list for old consumers.
|
||||
export function buildSidebarCategories(i18nState, user, features = {}) {
|
||||
const sections = buildSidebarSections(i18nState, user, features);
|
||||
return sections.flatMap((s) => s.categories);
|
||||
}
|
||||
// #endregion SidebarNavigation:Module
|
||||
|
||||
@@ -5,41 +5,51 @@
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"dashboard": "Dashboard",
|
||||
"section_resources": "Resources",
|
||||
"section_operations": "Operations",
|
||||
"section_system": "System",
|
||||
|
||||
"dashboards": "Dashboards",
|
||||
"datasets": "Datasets",
|
||||
"overview": "Overview",
|
||||
"all_datasets": "All Datasets",
|
||||
"dataset_review_workspace": "Review Workspace",
|
||||
"health_center": "Health Center",
|
||||
"storage": "Storage",
|
||||
"backups": "Backups",
|
||||
"repositories": "Repositories",
|
||||
"migration": "Migration",
|
||||
"migration_overview": "Overview",
|
||||
"migration_mappings": "Mappings",
|
||||
|
||||
"git": "Git",
|
||||
"tasks": "Tasks",
|
||||
"reports": "Reports",
|
||||
"settings": "Settings",
|
||||
"tools": "Tools",
|
||||
"tools_search": "Dataset Search",
|
||||
"tools_mapper": "Dataset Mapper",
|
||||
"tools_backups": "Backup Manager",
|
||||
"tools_debug": "System Debug",
|
||||
"tools_storage": "File Storage",
|
||||
"tools_llm": "LLM Tools",
|
||||
"git_repo_status": "Repository Status",
|
||||
|
||||
"translation": "Translation",
|
||||
"translation_jobs": "Jobs",
|
||||
"translation_dictionaries": "Dictionaries",
|
||||
"translation_history": "History",
|
||||
"settings_general": "General Settings",
|
||||
|
||||
"reports": "Reports",
|
||||
"all_reports": "All Reports",
|
||||
|
||||
"tools": "Tools",
|
||||
"tools_mapper": "Dataset Mapper",
|
||||
"tools_debug": "System Debug",
|
||||
"tools_storage": "File Storage",
|
||||
"tools_backups": "Backup Manager",
|
||||
|
||||
"storage": "Storage",
|
||||
"backups": "Backups",
|
||||
"repositories": "Repositories",
|
||||
|
||||
"settings": "Settings",
|
||||
"settings_general": "General",
|
||||
"settings_connections": "Connections",
|
||||
"settings_git": "Git Integration",
|
||||
"settings_environments": "Environments",
|
||||
"settings_storage": "Storage",
|
||||
|
||||
"admin": "Admin",
|
||||
"admin_users": "User Management",
|
||||
"admin_roles": "Role Management",
|
||||
"admin_settings": "ADFS Configuration",
|
||||
"admin_settings": "ADFS Config",
|
||||
"admin_llm": "LLM Providers",
|
||||
"profile": "Profile"
|
||||
|
||||
"profile": "Profile",
|
||||
"sign_out": "Sign Out"
|
||||
}
|
||||
|
||||
@@ -5,41 +5,51 @@
|
||||
"collapse": "Свернуть",
|
||||
"expand": "Развернуть",
|
||||
"expand_sidebar": "Развернуть боковую панель",
|
||||
"dashboard": "Панель управления",
|
||||
"section_resources": "Ресурсы",
|
||||
"section_operations": "Операции",
|
||||
"section_system": "Система",
|
||||
|
||||
"dashboards": "Дашборды",
|
||||
"datasets": "Датасеты",
|
||||
"overview": "Обзор",
|
||||
"all_datasets": "Все датасеты",
|
||||
"dataset_review_workspace": "Review Workspace",
|
||||
"health_center": "Центр здоровья",
|
||||
"storage": "Хранилище",
|
||||
"backups": "Бэкапы",
|
||||
"repositories": "Репозитории",
|
||||
"migration": "Миграция",
|
||||
"migration_overview": "Обзор",
|
||||
"migration_mappings": "Маппинги",
|
||||
|
||||
"git": "Git",
|
||||
"tasks": "Задачи",
|
||||
"reports": "Отчеты",
|
||||
"settings": "Настройки",
|
||||
"tools": "Инструменты",
|
||||
"tools_search": "Поиск датасетов",
|
||||
"tools_mapper": "Маппер колонок",
|
||||
"tools_backups": "Управление бэкапами",
|
||||
"tools_debug": "Диагностика системы",
|
||||
"tools_storage": "Хранилище файлов",
|
||||
"tools_llm": "Инструменты LLM",
|
||||
"git_repo_status": "Состояние репозитория",
|
||||
|
||||
"translation": "Переводы",
|
||||
"translation_jobs": "Задания",
|
||||
"translation_dictionaries": "Словари",
|
||||
"translation_history": "История",
|
||||
"settings_general": "Общие настройки",
|
||||
|
||||
"reports": "Отчеты",
|
||||
"all_reports": "Все отчеты",
|
||||
|
||||
"tools": "Инструменты",
|
||||
"tools_mapper": "Маппер колонок",
|
||||
"tools_debug": "Диагностика",
|
||||
"tools_storage": "Файловое хранилище",
|
||||
"tools_backups": "Бэкапы",
|
||||
|
||||
"storage": "Хранилище",
|
||||
"backups": "Бэкапы",
|
||||
"repositories": "Репозитории",
|
||||
|
||||
"settings": "Настройки",
|
||||
"settings_general": "Общие",
|
||||
"settings_connections": "Подключения",
|
||||
"settings_git": "Интеграция Git",
|
||||
"settings_environments": "Окружения",
|
||||
"settings_storage": "Хранилище",
|
||||
|
||||
"admin": "Админ",
|
||||
"admin_users": "Управление пользователями",
|
||||
"admin_roles": "Управление ролями",
|
||||
"admin_users": "Пользователи",
|
||||
"admin_roles": "Роли",
|
||||
"admin_settings": "Настройка ADFS",
|
||||
"admin_llm": "Провайдеры LLM",
|
||||
"profile": "Профиль"
|
||||
|
||||
"profile": "Профиль",
|
||||
"sign_out": "Выйти"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user