Backend: - agent: confirmation, persistence, app, langgraph_setup updates - routes: agent_superset_explore, environments, git helpers/operations - services: git sync refactoring - tests: git_status_route expanded Frontend: - Navbar: minor cleanup - Profile: i18n (en/ru), page enhancements, integration tests - New: _llm_params.py
388 lines
15 KiB
Svelte
388 lines
15 KiB
Svelte
<!-- #region ProfilePage [C:4] [TYPE Page] [SEMANTICS sveltekit, profile, preferences, user, settings] -->
|
|
<!-- @ingroup Routes -->
|
|
<!-- @BRIEF User profile page for viewing and editing personal preferences, integrations, notifications, and read-only access state. -->
|
|
<!-- @LAYER Page -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:api_module] -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
|
|
<!-- @UX_STATE Loading -> Profile data loading. -->
|
|
<!-- @UX_STATE Loaded -> Profile form displayed. -->
|
|
<!-- @UX_STATE Error -> Error toast shown while preserving defaults. -->
|
|
<!-- @UX_FEEDBACK Success toast on profile update. -->
|
|
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { t } from '$lib/i18n/index.svelte.js';
|
|
import { PageHeader, Button, Card, Input, Select, LanguageSwitcher } from '$lib/ui';
|
|
import { api } from '$lib/api.js';
|
|
import { addToast } from '$lib/toasts.svelte.js';
|
|
import { setTaskDrawerAutoOpenPreference } from '$lib/stores/taskDrawer.svelte.js';
|
|
|
|
type StartPage = 'dashboards' | 'datasets' | 'reports';
|
|
type TableDensity = 'compact' | 'comfortable';
|
|
|
|
type ProfilePermissionState = {
|
|
key: string;
|
|
allowed: boolean;
|
|
};
|
|
|
|
type ProfileSecuritySummary = {
|
|
read_only?: boolean;
|
|
auth_source?: string | null;
|
|
current_role?: string | null;
|
|
role_source?: string | null;
|
|
roles?: string[];
|
|
permissions?: ProfilePermissionState[];
|
|
};
|
|
|
|
type ProfilePreferences = {
|
|
user_id?: string;
|
|
superset_username: string;
|
|
show_only_my_dashboards: boolean;
|
|
show_only_slug_dashboards: boolean;
|
|
git_username: string;
|
|
git_email: string;
|
|
git_personal_access_token?: string | null;
|
|
has_git_personal_access_token?: boolean;
|
|
git_personal_access_token_masked?: string | null;
|
|
start_page: StartPage;
|
|
auto_open_task_drawer: boolean;
|
|
dashboards_table_density: TableDensity;
|
|
telegram_id: string;
|
|
email_address: string;
|
|
notify_on_fail: boolean;
|
|
};
|
|
|
|
type ProfileResponse = {
|
|
status?: string;
|
|
message?: string | null;
|
|
validation_errors?: string[];
|
|
preference?: Partial<ProfilePreferences>;
|
|
security?: ProfileSecuritySummary;
|
|
};
|
|
|
|
const defaultPreferences: ProfilePreferences = {
|
|
superset_username: '',
|
|
show_only_my_dashboards: false,
|
|
show_only_slug_dashboards: true,
|
|
git_username: '',
|
|
git_email: '',
|
|
git_personal_access_token: undefined,
|
|
has_git_personal_access_token: false,
|
|
git_personal_access_token_masked: null,
|
|
start_page: 'dashboards',
|
|
auto_open_task_drawer: true,
|
|
dashboards_table_density: 'comfortable',
|
|
telegram_id: '',
|
|
email_address: '',
|
|
notify_on_fail: true,
|
|
};
|
|
|
|
let preferences = $state<ProfilePreferences>({ ...defaultPreferences });
|
|
let security = $state<ProfileSecuritySummary>({ read_only: true, roles: [], permissions: [] });
|
|
let loading = $state(true);
|
|
let saving = $state(false);
|
|
let tokenInput = $state('');
|
|
let tokenTouched = $state(false);
|
|
|
|
const startPageOptions = $derived([
|
|
{ value: 'dashboards', label: $t.profile?.start_page_dashboards || $t.nav?.dashboards || 'Dashboards' },
|
|
{ value: 'datasets', label: $t.profile?.start_page_datasets || $t.nav?.datasets || 'Datasets' },
|
|
{ value: 'reports', label: $t.profile?.start_page_reports || $t.nav?.reports || 'Reports' },
|
|
]);
|
|
|
|
const tableDensityOptions = $derived([
|
|
{ value: 'comfortable', label: $t.profile?.table_density_comfortable || 'Comfortable' },
|
|
{ value: 'compact', label: $t.profile?.table_density_compact || 'Compact' },
|
|
]);
|
|
|
|
const saveLabel = $derived(
|
|
saving
|
|
? ($t.profile?.saving || 'Saving...')
|
|
: ($t.profile?.save_preferences || $t.common?.save || 'Save'),
|
|
);
|
|
|
|
function normalizePreferences(raw: Partial<ProfilePreferences> | undefined): ProfilePreferences {
|
|
return {
|
|
...defaultPreferences,
|
|
...raw,
|
|
superset_username: raw?.superset_username || '',
|
|
git_username: raw?.git_username || '',
|
|
git_email: raw?.git_email || '',
|
|
telegram_id: raw?.telegram_id || '',
|
|
email_address: raw?.email_address || '',
|
|
start_page: (raw?.start_page || defaultPreferences.start_page) as StartPage,
|
|
auto_open_task_drawer: raw?.auto_open_task_drawer !== false,
|
|
dashboards_table_density: (raw?.dashboards_table_density || defaultPreferences.dashboards_table_density) as TableDensity,
|
|
notify_on_fail: raw?.notify_on_fail !== false,
|
|
show_only_my_dashboards: raw?.show_only_my_dashboards === true,
|
|
show_only_slug_dashboards: raw?.show_only_slug_dashboards !== false,
|
|
};
|
|
}
|
|
|
|
function applyProfileResponse(response: ProfileResponse | undefined) {
|
|
preferences = normalizePreferences(response?.preference);
|
|
security = response?.security || { read_only: true, roles: [], permissions: [] };
|
|
tokenInput = '';
|
|
tokenTouched = false;
|
|
setTaskDrawerAutoOpenPreference(preferences.auto_open_task_drawer !== false);
|
|
}
|
|
|
|
function buildSavePayload() {
|
|
const payload: Record<string, unknown> = {
|
|
superset_username: preferences.superset_username || null,
|
|
show_only_my_dashboards: preferences.show_only_my_dashboards,
|
|
show_only_slug_dashboards: preferences.show_only_slug_dashboards,
|
|
git_username: preferences.git_username || null,
|
|
git_email: preferences.git_email || null,
|
|
start_page: preferences.start_page,
|
|
auto_open_task_drawer: preferences.auto_open_task_drawer,
|
|
dashboards_table_density: preferences.dashboards_table_density,
|
|
telegram_id: preferences.telegram_id || null,
|
|
email_address: preferences.email_address || null,
|
|
notify_on_fail: preferences.notify_on_fail,
|
|
};
|
|
|
|
if (tokenTouched) {
|
|
payload.git_personal_access_token = tokenInput || null;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function handleTokenInput(event: Event) {
|
|
tokenInput = (event.currentTarget as HTMLInputElement).value;
|
|
tokenTouched = true;
|
|
}
|
|
|
|
function handleTokenClear() {
|
|
tokenInput = '';
|
|
tokenTouched = true;
|
|
preferences.has_git_personal_access_token = false;
|
|
preferences.git_personal_access_token_masked = null;
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const response = await api.getProfilePreferences<ProfileResponse>();
|
|
applyProfileResponse(response);
|
|
} catch (e) {
|
|
addToast(e instanceof Error ? e.message : 'Failed to load preferences', 'error');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
});
|
|
|
|
async function handleSave() {
|
|
saving = true;
|
|
try {
|
|
const response = await api.updateProfilePreferences<ProfileResponse>(buildSavePayload());
|
|
applyProfileResponse(response);
|
|
addToast($t.profile?.save_success || $t.profile?.saved || 'Preferences saved', 'success');
|
|
} catch (e) {
|
|
addToast(e instanceof Error ? e.message : ($t.profile?.save_error || 'Failed to save preferences'), 'error');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
|
<PageHeader title={$t.profile?.title || $t.nav?.profile || 'Profile'} />
|
|
{#if $t.profile?.description}
|
|
<p class="-mt-6 mb-8 text-sm text-text-muted">{$t.profile.description}</p>
|
|
{/if}
|
|
|
|
{#if loading}
|
|
<div class="mt-6 animate-pulse space-y-4">
|
|
<div class="h-40 rounded-lg bg-surface-muted"></div>
|
|
<div class="h-56 rounded-lg bg-surface-muted"></div>
|
|
<div class="h-40 rounded-lg bg-surface-muted"></div>
|
|
</div>
|
|
{:else}
|
|
<div class="mt-6 space-y-6">
|
|
<Card title={$t.profile?.user_preferences || $t.profile?.preferences || 'User Preferences'} padding="lg">
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-text" for="profile-language">
|
|
{$t.profile?.language || 'Language'}
|
|
</label>
|
|
<div id="profile-language">
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
<Select
|
|
label={$t.profile?.start_page || 'Start Page'}
|
|
bind:value={preferences.start_page}
|
|
options={startPageOptions}
|
|
id="profile-start-page"
|
|
name="start_page"
|
|
/>
|
|
|
|
<Select
|
|
label={$t.profile?.table_density || 'Table Density'}
|
|
bind:value={preferences.dashboards_table_density}
|
|
options={tableDensityOptions}
|
|
id="profile-table-density"
|
|
name="dashboards_table_density"
|
|
/>
|
|
|
|
<label class="flex min-h-10 items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
|
|
<input
|
|
type="checkbox"
|
|
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
|
|
bind:checked={preferences.auto_open_task_drawer}
|
|
name="auto_open_task_drawer"
|
|
/>
|
|
<span>{$t.profile?.auto_open_task_drawer || 'Automatically open task drawer for long-running tasks'}</span>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={$t.profile?.dashboard_preferences || 'Dashboard Preferences'} padding="lg">
|
|
<div class="space-y-4">
|
|
<Input
|
|
label={$t.profile?.superset_account || 'Your Apache Superset Account'}
|
|
placeholder={$t.profile?.superset_account_placeholder || 'Enter your Apache Superset username'}
|
|
bind:value={preferences.superset_username}
|
|
id="profile-superset-username"
|
|
name="superset_username"
|
|
/>
|
|
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
|
|
<input
|
|
type="checkbox"
|
|
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
|
|
bind:checked={preferences.show_only_my_dashboards}
|
|
name="show_only_my_dashboards"
|
|
/>
|
|
<span>{$t.profile?.show_only_my_dashboards || 'Show only my dashboards by default'}</span>
|
|
</label>
|
|
|
|
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
|
|
<input
|
|
type="checkbox"
|
|
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
|
|
bind:checked={preferences.show_only_slug_dashboards}
|
|
name="show_only_slug_dashboards"
|
|
/>
|
|
<span>{$t.profile?.show_only_slug_dashboards || 'Show only dashboards with slug by default'}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={$t.profile?.git_integration || 'Git Integration'} padding="lg">
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<Input
|
|
label={$t.profile?.git_username || 'Git Username'}
|
|
placeholder={$t.profile?.git_username_placeholder || 'Enter git username'}
|
|
bind:value={preferences.git_username}
|
|
id="profile-git-username"
|
|
name="git_username"
|
|
/>
|
|
|
|
<Input
|
|
label={$t.profile?.git_email || 'Git Email'}
|
|
placeholder={$t.profile?.git_email_placeholder || 'Enter git email'}
|
|
bind:value={preferences.git_email}
|
|
id="profile-git-email"
|
|
name="git_email"
|
|
type="email"
|
|
/>
|
|
|
|
<div class="md:col-span-2 space-y-2">
|
|
<Input
|
|
label={$t.profile?.git_token || 'GitLab / GitHub Token'}
|
|
placeholder={$t.profile?.git_token_placeholder || 'Enter new personal access token'}
|
|
value={tokenInput}
|
|
oninput={handleTokenInput}
|
|
id="profile-git-token"
|
|
name="git_personal_access_token"
|
|
type="password"
|
|
/>
|
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-text-muted">
|
|
<span>
|
|
{$t.profile?.git_token_hint || 'Token is never returned in plain text. Leave empty to keep current token.'}
|
|
</span>
|
|
<span class="text-text">
|
|
{$t.profile?.git_token_masked_label || 'Current token'}:
|
|
{preferences.git_personal_access_token_masked || $t.profile?.git_token_not_set || 'Token is not set'}
|
|
</span>
|
|
</div>
|
|
<Button variant="secondary" size="sm" onclick={handleTokenClear}>
|
|
{$t.profile?.git_token_clear || 'Clear token'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={$t.profile?.notifications || 'Notifications'} padding="lg">
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<Input
|
|
label={$t.profile?.notification_email || 'Notification email'}
|
|
placeholder={$t.profile?.notification_email_placeholder || 'Enter notification email'}
|
|
bind:value={preferences.email_address}
|
|
id="profile-email-address"
|
|
name="email_address"
|
|
type="email"
|
|
/>
|
|
|
|
<Input
|
|
label={$t.profile?.telegram_id || 'Telegram ID'}
|
|
placeholder={$t.profile?.telegram_id_placeholder || 'Enter Telegram ID'}
|
|
bind:value={preferences.telegram_id}
|
|
id="profile-telegram-id"
|
|
name="telegram_id"
|
|
/>
|
|
|
|
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text md:col-span-2">
|
|
<input
|
|
type="checkbox"
|
|
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
|
|
bind:checked={preferences.notify_on_fail}
|
|
name="notify_on_fail"
|
|
/>
|
|
<span>{$t.profile?.notify_on_fail || 'Notify on validation failure'}</span>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={$t.profile?.security_access || 'Security & Access'} padding="lg">
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.current_role || 'Current Role'}</p>
|
|
<p class="mt-1 text-sm text-text">{security.current_role || security.roles?.[0] || '-'}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.role_source || 'Role Source'}</p>
|
|
<p class="mt-1 text-sm text-text">{security.role_source || security.auth_source || '-'}</p>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.permissions || 'Permissions'}</p>
|
|
{#if security.permissions?.length}
|
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
{#each security.permissions as permission}
|
|
<span class="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs text-text-muted">
|
|
{permission.key}: {permission.allowed ? 'allowed' : 'denied'}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="mt-1 text-sm text-text-muted">{$t.profile?.permission_none || 'No permissions available'}</p>
|
|
{/if}
|
|
</div>
|
|
<p class="md:col-span-2 text-xs text-text-muted">
|
|
{$t.profile?.security_read_only_note || 'This section is read-only. Role changes are managed in Admin -> Users.'}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<div class="flex justify-end">
|
|
<Button onclick={handleSave} isLoading={saving}>{saveLabel}</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- #endregion ProfilePage -->
|