feat(reports): add task status settings and tests
This commit is contained in:
@@ -130,6 +130,45 @@ describe("TaskCenterModel — L1 invariants", () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(model.screenState).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("reconnect uses exponential delays and resets after successful open", () => {
|
||||
let wsOnclose: ((e: any) => void) | null = null;
|
||||
let wsOnopen: (() => void) | null = null;
|
||||
const instances: any[] = [];
|
||||
class FakeWS {
|
||||
close = vi.fn();
|
||||
constructor() { instances.push(this); }
|
||||
set onclose(fn: ((e: any) => void) | null) { wsOnclose = fn; }
|
||||
get onclose() { return wsOnclose; }
|
||||
set onopen(fn: (() => void) | null) { wsOnopen = fn; }
|
||||
get onopen() { return wsOnopen; }
|
||||
set onerror(_fn: any) {}
|
||||
get onerror() { return null; }
|
||||
set onmessage(_fn: any) {}
|
||||
get onmessage() { return null; }
|
||||
}
|
||||
global.WebSocket = FakeWS as any;
|
||||
|
||||
model.connectWebSocket();
|
||||
wsOnclose?.({ code: 1006 } as CloseEvent);
|
||||
expect(model["_reconnectAttempt"]).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(instances.length).toBe(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(instances.length).toBe(2);
|
||||
|
||||
wsOnclose?.({ code: 1006 } as CloseEvent);
|
||||
expect(model["_reconnectAttempt"]).toBe(2);
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(instances.length).toBe(2);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(instances.length).toBe(3);
|
||||
|
||||
wsOnopen?.();
|
||||
expect(model.wsConnected).toBe(true);
|
||||
expect(model["_reconnectAttempt"]).toBe(0);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TaskCenterModel.ToggleVisibility
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<!-- @RELATION DEPENDS_ON -> [MigrationSettings] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [StorageSettings] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [FeaturesSettings] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [ReportsSettings] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:SettingsUtils] -->
|
||||
<!-- @PRE Route is loaded in the authenticated UI shell. -->
|
||||
<!-- @POST Page exposes consolidated settings tabs; each tab manages its own state. -->
|
||||
@@ -42,6 +43,7 @@
|
||||
import MigrationSettings from "./MigrationSettings.svelte";
|
||||
import StorageSettings from "./StorageSettings.svelte";
|
||||
import FeaturesSettings from "./FeaturesSettings.svelte";
|
||||
import ReportsSettings from "./ReportsSettings.svelte";
|
||||
import SystemSettings from "./SystemSettings.svelte";
|
||||
import GitSettingsPage from "./git/+page.svelte";
|
||||
import AutomationSettingsPage from "./automation/+page.svelte";
|
||||
@@ -253,6 +255,19 @@
|
||||
>
|
||||
{$t.settings?.features || "Features"}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
|
||||
class:text-primary={activeTab === "reports"}
|
||||
class:border-primary={activeTab === "reports"}
|
||||
class:text-text-muted={activeTab !== "reports"}
|
||||
class:border-transparent={activeTab !== "reports"}
|
||||
class:hover:text-text={activeTab !== "reports"}
|
||||
class:hover:border-border-strong={activeTab !== "reports"}
|
||||
aria-current={activeTab === "reports" ? "page" : undefined}
|
||||
onclick={() => handleTabChange("reports")}
|
||||
>
|
||||
Отчёты
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
|
||||
class:text-primary={activeTab === "automation"}
|
||||
@@ -312,6 +327,8 @@
|
||||
<StorageSettings bind:settings onSave={handleSave} />
|
||||
{:else if activeTab === "features"}
|
||||
<FeaturesSettings bind:settings onSave={handleSave} />
|
||||
{:else if activeTab === "reports"}
|
||||
<ReportsSettings />
|
||||
{:else if activeTab === "automation"}
|
||||
<AutomationSettingsPage />
|
||||
{:else if activeTab === "languages"}
|
||||
|
||||
123
frontend/src/routes/settings/ReportsSettings.svelte
Normal file
123
frontend/src/routes/settings/ReportsSettings.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<!-- #region ReportsSettings [C:4] [TYPE Component] [SEMANTICS settings,reports,task-status-center] -->
|
||||
<!-- @BRIEF Admin settings for globally disabling Task Status Center task types. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION CALLS -> [ReportsApi.getReportsSettings] -->
|
||||
<!-- @RELATION CALLS -> [ReportsApi.updateReportsSettings] -->
|
||||
<!-- @PRE User is authenticated; save requires settings:WRITE or Admin role. -->
|
||||
<!-- @POST Disabled task types are loaded and saved through reports settings API. -->
|
||||
<!-- @UX_STATE loading: spinner copy; loaded: checkbox list; saving: save button busy; error: inline alert. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '$lib/ui';
|
||||
import { auth, type AuthState } from '$lib/auth/store.svelte';
|
||||
import { hasPermission } from '$lib/auth/permissions';
|
||||
import { getReportsSettings, updateReportsSettings } from '$lib/api/reports';
|
||||
import { REPORT_TYPE_PROFILES } from '$lib/components/reports/reportTypeProfiles';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import type { TaskType } from '$types/reports';
|
||||
|
||||
const taskTypes: TaskType[] = ['llm_verification', 'backup', 'migration', 'documentation', 'clean_release', 'unknown'];
|
||||
|
||||
let disabledTypes: TaskType[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
let isSaving = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let authState: AuthState | null = $state(null);
|
||||
const canWrite = $derived(hasPermission(authState?.user, 'settings:WRITE'));
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = auth.subscribe((state) => {
|
||||
authState = state;
|
||||
});
|
||||
void loadReportsSettings();
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
async function loadReportsSettings(): Promise<void> {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const settings = await getReportsSettings();
|
||||
disabledTypes = settings.disabled_task_types ?? [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Не удалось загрузить настройки отчётов';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleType(type: TaskType): void {
|
||||
if (!canWrite) return;
|
||||
disabledTypes = disabledTypes.includes(type)
|
||||
? disabledTypes.filter((item) => item !== type)
|
||||
: [...disabledTypes, type];
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
isSaving = true;
|
||||
error = null;
|
||||
try {
|
||||
const saved = await updateReportsSettings({ disabled_task_types: disabledTypes });
|
||||
disabledTypes = saved.disabled_task_types ?? [];
|
||||
addToast('Настройки отчётов сохранены', 'success');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Не удалось сохранить настройки отчётов';
|
||||
addToast('Не удалось сохранить настройки отчётов', 'error');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type: TaskType): string {
|
||||
const profile = REPORT_TYPE_PROFILES[type];
|
||||
return typeof profile?.label === 'function' ? profile.label() : profile?.label ?? type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-4" aria-label="Настройки отчётов">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text">Отчёты</h2>
|
||||
<p class="mt-1 text-sm text-text-muted">
|
||||
Отключите типы задач, которые не должны отображаться в Task Status Center для всех пользователей.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-destructive-ring bg-destructive-light p-3 text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="rounded-lg bg-surface-muted p-4 text-sm text-text-muted">Загрузка настроек отчётов...</div>
|
||||
{:else}
|
||||
{#if !canWrite}
|
||||
<div class="rounded-lg border border-warning-ring bg-warning-light p-3 text-sm text-warning">
|
||||
У вас нет прав на изменение настроек отчётов. Доступен только просмотр.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each taskTypes as type (type)}
|
||||
<label class="flex items-start gap-3 rounded-lg border border-border bg-surface-page p-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary-ring"
|
||||
checked={disabledTypes.includes(type)}
|
||||
disabled={!canWrite || isSaving}
|
||||
onchange={() => toggleType(type)}
|
||||
/>
|
||||
<span>
|
||||
<span class="block font-medium text-text">{typeLabel(type)}</span>
|
||||
<span class="block text-xs text-text-muted">{disabledTypes.includes(type) ? 'Скрыто в отчётах' : 'Отображается в отчётах'}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div class="flex justify-end">
|
||||
<Button variant="primary" isLoading={isSaving} onclick={save}>Сохранить</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
<!-- #endregion ReportsSettings -->
|
||||
@@ -17,6 +17,7 @@ export const SETTINGS_TABS = [
|
||||
"migration",
|
||||
"storage",
|
||||
"features",
|
||||
"reports",
|
||||
"automation",
|
||||
"languages",
|
||||
"system",
|
||||
|
||||
Reference in New Issue
Block a user