feat(reports): add task status settings and tests

This commit is contained in:
2026-07-06 01:22:39 +03:00
parent 99bbdb4398
commit 28cd141e76
9 changed files with 333 additions and 55 deletions

View File

@@ -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

View File

@@ -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"}

View 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 -->

View File

@@ -17,6 +17,7 @@ export const SETTINGS_TABS = [
"migration",
"storage",
"features",
"reports",
"automation",
"languages",
"system",