From 3c2c75e8feaf62c96b7304f4af583707b31ae03a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:41:58 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D0=B8=20=C2=AB=D0=9E=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B8=D0=B7=20Jira?= =?UTF-8?q?=C2=BB=20=E2=80=94=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20M-1=E2=80=A6M-5=20#20=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/status.test.ts | 19 +++ web/src/board/status.ts | 38 +++++ web/src/board/useBoardSource.test.tsx | 181 ++++++++++++++++++++++++ web/src/board/useBoardSource.ts | 192 ++++++++++++++++++++++++++ 4 files changed, 430 insertions(+) create mode 100644 web/src/board/status.test.ts create mode 100644 web/src/board/status.ts create mode 100644 web/src/board/useBoardSource.test.tsx create mode 100644 web/src/board/useBoardSource.ts diff --git a/web/src/board/status.test.ts b/web/src/board/status.test.ts new file mode 100644 index 0000000..4d44af7 --- /dev/null +++ b/web/src/board/status.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { statusText } from "./status.ts"; + +describe("statusText", () => { + it("пакет: офлайн, очередь, остаток, повтор", () => { + expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false })) + .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false }); + expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true })) + .toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true }); + }); + it("исходы и ошибки", () => { + expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true }); + expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text) + .toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна"); + expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные"); + expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text) + .toBe("файл не разобран: в файле нет строк с задачами"); + }); +}); diff --git a/web/src/board/status.ts b/web/src/board/status.ts new file mode 100644 index 0000000..169a5f7 --- /dev/null +++ b/web/src/board/status.ts @@ -0,0 +1,38 @@ +export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html) +export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4) + +export type BoardStatus = + | { kind: "loading" } + | { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean } + | { kind: "batch-done"; id: number } + | { kind: "batch-failed"; id: number; error: string } + | { kind: "batch-cancelled"; id: number } + | { kind: "batch-lost"; id: number; reason: string } + | { kind: "hub-down"; hasData: boolean } + | { kind: "file-error"; fileName: string; reason: string } + | { kind: "json-applied"; fileName: string } + | { kind: "error"; message: string }; + +// одна строка состояния доски: текст и признак предупреждения (M-5) +export function statusText(s: BoardStatus): { text: string; warn: boolean } { + switch (s.kind) { + case "loading": return { text: "загрузка данных хаба…", warn: false }; + case "batch": { + const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : ""; + const left = s.remaining ? `, осталось ~${s.remaining} с` : ""; + const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : ""; + return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying }; + } + case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false }; + case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true }; + case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true }; + case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true }; + case "hub-down": return { + text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»", + warn: true, + }; + case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true }; + case "json-applied": return { text: `раскладка и фильтры из ${s.fileName} применены к данным хаба`, warn: false }; + case "error": return { text: s.message, warn: true }; + } +} diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx new file mode 100644 index 0000000..dfd30c1 --- /dev/null +++ b/web/src/board/useBoardSource.test.tsx @@ -0,0 +1,181 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { makeQueryClient } from "../api/queryClient.ts"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { dictsFromSettings } from "./dicts.ts"; +import settings from "./__fixtures__/settings.json"; +import { useBoardSource } from "./useBoardSource.ts"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); +const SNAP = JSON.parse(fx("hub-basic.board.json")); +const CSV = fx("jira-export.csv"); +const { dicts } = dictsFromSettings(settings); +const wrapper = ({ children }: { children: ReactNode }) => + {children}; +const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper }); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; + +// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов +function hub(over: Partial Response | Promise>> = {}) { + const calls: string[] = []; + const inFlight: Record = {}, maxInFlight: Record = {}; + const fetch = mockFetch(async (method, url, body) => { + const k = `${method} ${url.pathname}`; + calls.push(k); + inFlight[k] = (inFlight[k] ?? 0) + 1; + maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]); + try { + const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`]; + if (h) return await h(url, body); + if (k === "GET /board") return json(200, SNAP); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null }); + if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 }); + return json(404, { error: "not_found" }); + } finally { inFlight[k] -= 1; } + }); + return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 }; +} +const batch = (status: string, cmd: Record = { status: "done" }) => + json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] }); + +describe("useBoardSource", () => { + beforeEach(() => setToken("tok")); + + it("строит доску из снимка хаба и подписывает источник", async () => { + hub(); + const { result } = hook(); + expect(result.current.status).toEqual({ kind: "loading" }); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + expect(result.current.source).toBe("hub"); + expect(result.current.label).toMatch(/^Хаб · данные на /); + expect(result.current.status).toBeNull(); + }); + + it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => { + const slow = deferred(); + const h = hub({ "GET /board": () => slow.promise }); + const { result } = hook(); + act(() => { result.current.loadFile("jira-export.csv", CSV); }); + expect(result.current.source).toBe("file"); + slow.resolve(json(200, SNAP)); + await waitFor(() => expect(h.count("GET /board")).toBe(1)); + await sleep(20); + expect(result.current.source).toBe("file"); + expect(result.current.label).toBe("jira-export.csv (из файла)"); + act(() => result.current.reload()); + await waitFor(() => expect(result.current.source).toBe("hub")); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + }); + + it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => { + let polls = 0; + const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); }); + expect(h.count("POST /refresh")).toBe(1); + expect(result.current.refreshing).toBe(true); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); + expect(result.current.refreshing).toBe(false); + expect(h.maxInFlight("GET /batches/7")).toBe(1); + expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан + }); + + it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => { + let listed: unknown[] = []; + const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") }); + const { result } = hook(); + await waitFor(() => expect(h.count("GET /batches")).toBe(1)); + expect(result.current.refreshing).toBe(false); + listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }]; + act(() => result.current.reload()); + await waitFor(() => expect(result.current.refreshing).toBe(true)); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" })); + }); + + it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => { + const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 })); + expect(result.current.refreshing).toBe(false); + const n = h.count("GET /batches/7"); + await sleep(50); + expect(h.count("GET /batches/7")).toBe(n); + }); + + it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => { + const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" })); + expect(h.count("GET /batches/7")).toBe(5); + await sleep(50); + expect(h.count("GET /batches/7")).toBe(5); + expect(result.current.refreshing).toBe(false); + }); + + it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => { + let polls = 0; + hub({ + "GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }), + "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })), + }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true })); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" })); + }); + + it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => { + let polls = 0; + hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + act(() => { result.current.loadFile("jira-export.csv", CSV); }); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); + expect(result.current.source).toBe("file"); + }); + + it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => { + let down = false; + hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + down = true; + act(() => result.current.reload()); + await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true })); + expect(result.current.model.nodes.length).toBe(10); + }); + + it("плохой файл — file-error, прежняя доска на месте", async () => { + hub(); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); }); + expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" }); + expect(result.current.source).toBe("hub"); + }); + + it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => { + hub(); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + let r: unknown = null; + act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); }); + expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] }); + expect(result.current.source).toBe("hub"); + expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" }); + }); +}); diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts new file mode 100644 index 0000000..bad97f0 --- /dev/null +++ b/web/src/board/useBoardSource.ts @@ -0,0 +1,192 @@ +// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON); обновление из Jira — пакет refresh от имени +// viewer и опрос его хода. Устройство закрывает недочёты jira-hub#20: явный источник (M-1), одна кнопка и один опрос +// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5) +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { api, ApiError, AuthError, HubDownError } from "../api/client.ts"; +import { qk, useBoard } from "../api/queries.ts"; +import type { AgentView, Batch, CreatedBatch } from "../api/types.ts"; +import { clearToken } from "../auth/token.ts"; +import { fromCsv } from "./model/fromCsv.ts"; +import { fromHub } from "./model/fromHub.ts"; +import { fromJson } from "./model/fromJson.ts"; +import { emptyModel, fmtStamp } from "./model/util.ts"; +import { MAX_POLL_ERRORS, POLL_MS, type BoardStatus } from "./status.ts"; +import type { BoardModel, Dicts, JsonImport } from "./types.ts"; + +type Source = { kind: "hub" } | { kind: "file"; model: BoardModel }; +interface Active { id: number; agentOffline: boolean } + +export interface BoardSource { + model: BoardModel; + source: "hub" | "file"; + label: string; + title: string; + status: BoardStatus | null; + refreshing: boolean; + reload(): void; + refreshFromJira(): Promise; + loadFile(fileName: string, text: string): JsonImport | null; +} + +const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); + +export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource { + const qc = useQueryClient(); + const board = useBoard(); + const [source, setSource] = useState({ kind: "hub" }); + const [event, setEvent] = useState(null); + const [active, setActive] = useState(null); + const [starting, setStarting] = useState(false); + const startingRef = useRef(false); + const failures = useRef(0); + + const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]); + + const report = useCallback((e: unknown, hasData: boolean) => { + if (e instanceof AuthError) clearToken("bad"); + else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData }); + else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + }, []); + + // хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими) + useEffect(() => { + if (board.error && !(board.error instanceof AuthError)) report(board.error, !!board.data); + }, [board.error, board.errorUpdatedAt, board.data, report]); + + // M-3: после каждой успешной загрузки снимка — подхватить незавершённый пакет viewer + useEffect(() => { + if (!board.dataUpdatedAt || active) return; + let gone = false; + api.get<{ batches: Batch[] }>("/batches", { limit: 20 }).then((r) => { + const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running")); + if (b && !gone) { failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); } + }).catch(() => { /* не критично: подхватим при следующей загрузке */ }); + return () => { gone = true; }; + }, [board.dataUpdatedAt, active]); + + // M-2, M-4: один опрос пакета; остановка на завершении, 404, 401 и после MAX_POLL_ERRORS ошибок подряд + const batch = useQuery({ + queryKey: ["batch", active?.id ?? 0], + enabled: active !== null, + gcTime: 0, + staleTime: 0, + queryFn: async () => { + try { + const b = await api.get(`/batches/${active!.id}`); + failures.current = 0; + return b; + } catch (e) { + if (e instanceof HubDownError) failures.current += 1; + throw e; + } + }, + refetchInterval: (q) => { + const e = q.state.error; + if (terminal(q.state.data)) return false; + if (e instanceof AuthError || (e instanceof ApiError && e.status === 404)) return false; + if (failures.current >= MAX_POLL_ERRORS) return false; + return pollMs; + }, + refetchIntervalInBackground: true, + }); + + const finish = useCallback((id: number) => { + failures.current = 0; + setActive(null); + qc.removeQueries({ queryKey: ["batch", id] }); + void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1) + }, [qc]); + + useEffect(() => { + if (!active) return; + const b = batch.data, e = batch.error; + if (b && b.id === active.id && !batch.isError) { + if (terminal(b)) { + const cmd = b.commands?.[0]; + setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" } + : b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id } + : { kind: "batch-done", id: b.id }); + finish(b.id); + return; + } + setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running", + remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false }); + return; + } + if (e instanceof ApiError && e.status === 404) { + setEvent({ kind: "batch-lost", id: active.id, reason: "пакета нет в хабе" }); + finish(active.id); + } else if (e instanceof HubDownError) { + if (failures.current >= MAX_POLL_ERRORS) { + setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" }); + finish(active.id); + } else { + setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur)); + } + } + }, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]); + + const refreshFromJira = useCallback(async () => { + if (startingRef.current || active) return; + startingRef.current = true; + setStarting(true); + try { + const agent = await api.get("/agent"); + const b = await api.post("/refresh", { full: true, author: "viewer" }); + failures.current = 0; + setActive({ id: b.id, agentOffline: !agent.online }); + setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false }); + } catch (e) { + report(e, !!board.data); + } finally { + startingRef.current = false; + setStarting(false); + } + }, [active, report, board.data]); + + const reload = useCallback(() => { + setSource({ kind: "hub" }); + setEvent(null); + void qc.refetchQueries({ queryKey: qk.board }); + }, [qc]); + + const loadFile = useCallback((fileName: string, text: string): JsonImport | null => { + if (!dicts) return null; + const isJson = /\.json$/i.test(fileName) || /^\uFEFF?\s*\{/.test(text); + try { + if (!isJson) { + setSource({ kind: "file", model: fromCsv(text, fileName, dicts) }); + setEvent(null); + return null; + } + const r = fromJson(text, fileName, dicts); + if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба + setSource({ kind: "hub" }); + setEvent({ kind: "json-applied", fileName }); + void qc.refetchQueries({ queryKey: qk.board }); + } else { // хаб не ответил — снимок из файла + setSource({ kind: "file", model: r.model }); + setEvent({ kind: "hub-down", hasData: true }); + } + return r; + } catch (e) { + setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) }); + return null; + } + }, [dicts, board.data, qc]); + + const ls = board.data?.last_sync ?? null; + const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending + ? "Загрузка данных хаба…" + : "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."); + const label = source.kind === "file" ? `${source.model.fileName} (из файла)` + : board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было") + : board.isPending ? "Хаб" : "Хаб недоступен"; + const title = source.kind === "file" ? `${source.model.fileName} — снимок из файла; «Перечитать» вернёт данные хаба` + : ls && board.data ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(board.data.generated_at)}` + : label; + const status: BoardStatus | null = event ?? (source.kind === "hub" && board.isPending ? { kind: "loading" } : null); + + return { model, source: source.kind, label, title, status, refreshing: starting || active !== null, reload, refreshFromJira, loadFile }; +}