Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions app/components/dash/Failures.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { useQueryClient } from "@tanstack/vue-query"
import type { FailedGroupEntry } from "~/queries/dash"
import { dismissFailure, refetchGroup } from "~/queries/dash"
import { fmt, groupLabel } from "~/composables/useDash"
import { useClipboard } from "@vueuse/core"

Expand All @@ -9,6 +11,7 @@ defineProps<{
}>()

const { copy } = useClipboard({ legacy: true })
const queryClient = useQueryClient()

// Copied tracking per entry
const copiedId = ref<string | null>(null)
Expand All @@ -18,6 +21,47 @@ function copyError(key: string, text: string) {
copiedId.value = key
setTimeout(() => (copiedId.value = null), 2000)
}

// Per-row busy tracking for dismiss/refetch actions
const busyKeys = ref(new Set<string>())

async function handleDismiss(f: FailedGroupEntry) {
const key = `${f.runId}-${f.groupId}`
busyKeys.value.add(key)
try {
const result = await dismissFailure(f.runId, f.groupId)
if (result?.dismissed) {
useSonner.success("Приховано", { description: groupLabel(f) })
}
} catch {
useSonner.error("Не вдалося приховати", {
description: "Запис уже міг змінитися. Оновлюємо список.",
})
} finally {
busyKeys.value.delete(key)
await queryClient.invalidateQueries({ queryKey: ["dash"] })
}
}

async function handleRefetch(f: FailedGroupEntry) {
const key = `${f.runId}-${f.groupId}`
busyKeys.value.add(key)
try {
const result = await refetchGroup(f.groupId)
if (result?.ok) {
useSonner.success("Перезапит виконано", {
description: `${groupLabel(f)} — ${result.eventsCount} подій`,
})
} else {
useSonner.error("Перезапит не вдався", { description: result?.error ?? groupLabel(f) })
}
} catch {
useSonner.error("Перезапит не вдався", { description: groupLabel(f) })
} finally {
busyKeys.value.delete(key)
await queryClient.invalidateQueries({ queryKey: ["dash"] })
}
}
</script>

<template>
Expand Down Expand Up @@ -63,6 +107,32 @@ function copyError(key: string, text: string) {
>Прогін <span class="font-mono">#{{ f.runId }}</span></span
>
<span class="font-mono">{{ fmt(f.finishedAt) }}</span>
<UiButton
variant="outline"
size="sm"
class="h-6 gap-1 px-2 text-[11px]"
:disabled="busyKeys.has(`${f.runId}-${f.groupId}`)"
@click="handleRefetch(f)"
>
<AppIcon
name="ph:arrows-clockwise"
class="size-3"
:class="{
'animate-spin': busyKeys.has(`${f.runId}-${f.groupId}`),
}"
/>
Перезапит
</UiButton>
<UiButton
variant="ghost"
size="sm"
class="h-6 gap-1 px-2 text-[11px]"
:disabled="busyKeys.has(`${f.runId}-${f.groupId}`)"
@click="handleDismiss(f)"
>
<AppIcon name="ph:eye-slash" class="size-3" />
Приховати
</UiButton>
</div>
</div>

Expand Down
46 changes: 43 additions & 3 deletions app/components/dash/Groups.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"
import type { SyncRun } from "~/queries/dash"
import { dashRunGroupsOptions } from "~/queries/dash"
import { useQuery, useQueryClient } from "@tanstack/vue-query"
import type { SyncRun, SyncRunGroup } from "~/queries/dash"
import { dashRunGroupsOptions, refetchGroup } from "~/queries/dash"
import { fmt, fmtShort, deltaLabel, deltaClass, groupLabel } from "~/composables/useDash"

const props = defineProps<{
Expand Down Expand Up @@ -41,6 +41,29 @@ const visibleGroups = computed(() => {
})

const failedCount = computed(() => (groups.value ?? []).filter((g) => g.status === "failed").length)

// Manual refetch — any group, regardless of status
const queryClient = useQueryClient()
const busyIds = ref(new Set<number>())

async function handleRefetch(g: SyncRunGroup) {
busyIds.value.add(g.groupId)
try {
const result = await refetchGroup(g.groupId)
if (result?.ok) {
useSonner.success("Перезапит виконано", {
description: `${groupLabel(g)} — ${result.eventsCount} подій`,
})
} else {
useSonner.error("Перезапит не вдався", { description: result?.error ?? groupLabel(g) })
}
} catch {
useSonner.error("Перезапит не вдався", { description: groupLabel(g) })
} finally {
busyIds.value.delete(g.groupId)
await queryClient.invalidateQueries({ queryKey: ["dash"] })
}
}
</script>

<template>
Expand Down Expand Up @@ -138,6 +161,7 @@ const failedCount = computed(() => (groups.value ?? []).filter((g) => g.status =
<span class="min-w-0 flex-1">Група</span>
<span class="font-sans">Подій</span>
<span class="w-24 shrink-0 text-right">Оновлено</span>
<span class="size-6 shrink-0" />
</div>

<!-- Group rows — content-visibility:auto for 1000+ rows -->
Expand Down Expand Up @@ -179,6 +203,22 @@ const failedCount = computed(() => (groups.value ?? []).filter((g) => g.status =
>
{{ g.error }}
</span>

<!-- Refetch -->
<button
class="text-muted-foreground hover:text-foreground flex size-6 shrink-0
items-center justify-center rounded transition-colors disabled:opacity-50"
:disabled="busyIds.has(g.groupId)"
:aria-label="`Перезапит групи ${groupLabel(g)}`"
:title="`Перезапит групи ${groupLabel(g)}`"
@click="handleRefetch(g)"
>
<AppIcon
name="ph:arrows-clockwise"
class="size-3.5"
:class="{ 'animate-spin': busyIds.has(g.groupId) }"
/>
</button>
</div>
</div>
</div>
Expand Down
105 changes: 84 additions & 21 deletions app/components/dash/RunItem.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"
import type { SyncRun } from "~/queries/dash"
import type { SyncRun, StepResult, ManualRefetchStep } from "~/queries/dash"
import { dashRunGroupsOptions } from "~/queries/dash"
import {
TRIGGER_LABELS,
Expand Down Expand Up @@ -38,6 +38,28 @@ const sparkWidth = computed(() => {
return Math.max(2, Math.round((ms / props.maxDurationMs) * 100))
})

// Steps timeline — discriminate the three shapes that can appear in run.steps
// (fixed StepResult keys, optional phantomSkip count-only, optional manualRefetch)
type StepEntry =
| { kind: "standard"; key: string; label: string; step: StepResult }
| { kind: "phantomSkip"; count: number }
| { kind: "manualRefetch"; step: ManualRefetchStep }

const stepEntries = computed<StepEntry[]>(() => {
const s = props.run.steps
const entries: StepEntry[] = (["auditoriums", "groups", "teachers"] as const)
.filter((key) => s[key])
.map((key) => ({
kind: "standard",
key,
label: STEP_LABELS[key] ?? key,
step: s[key]!,
}))
if (s.phantomSkip) entries.push({ kind: "phantomSkip", count: s.phantomSkip.count })
if (s.manualRefetch) entries.push({ kind: "manualRefetch", step: s.manualRefetch })
return entries
})

// Groups split
const failedGroups = computed(() => groups.value?.filter((g) => g.status === "failed") ?? [])
const successGroups = computed(() => groups.value?.filter((g) => g.status === "success") ?? [])
Expand Down Expand Up @@ -189,26 +211,67 @@ const { copy, copied } = useClipboard({ legacy: true })
<!-- Steps timeline -->
<div class="bg-muted/30 space-y-2 rounded-lg border p-3">
<p class="text-muted-foreground mb-2 text-xs font-medium">Кроки синхронізації</p>
<div v-for="(step, key) in run.steps" :key="key" class="flex items-start gap-2.5">
<AppIcon
:name="step.ok ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
class="mt-0.5 size-4 shrink-0"
:class="step.ok ? 'text-green-500' : 'text-destructive'"
/>
<div class="min-w-0">
<span class="text-sm font-medium">
{{ STEP_LABELS[String(key)] ?? String(key) }}
</span>
<span class="text-muted-foreground ml-1.5 text-xs">
{{ step.count }} оброблено
</span>
<span
v-if="!step.ok && step.error"
class="text-destructive mt-0.5 block text-xs"
>
{{ step.error }}
</span>
</div>
<div v-for="(entry, i) in stepEntries" :key="i" class="flex items-start gap-2.5">
<!-- Standard step (auditoriums / groups / teachers) -->
<template v-if="entry.kind === 'standard'">
<AppIcon
:name="entry.step.ok ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
class="mt-0.5 size-4 shrink-0"
:class="entry.step.ok ? 'text-green-500' : 'text-destructive'"
/>
<div class="min-w-0">
<span class="text-sm font-medium">{{ entry.label }}</span>
<span class="text-muted-foreground ml-1.5 text-xs">
{{ entry.step.count }} оброблено
</span>
<span
v-if="!entry.step.ok && entry.step.error"
class="text-destructive mt-0.5 block text-xs"
>
{{ entry.step.error }}
</span>
</div>
</template>

<!-- Phantom-skip (count only, no ok/error) -->
<template v-else-if="entry.kind === 'phantomSkip'">
<AppIcon
name="ph:skip-forward"
class="text-muted-foreground mt-0.5 size-4 shrink-0"
/>
<div class="min-w-0">
<span class="text-sm font-medium">
{{ STEP_LABELS.phantomSkip }}
</span>
<span class="text-muted-foreground ml-1.5 text-xs">
{{ entry.count }} пропущено
</span>
</div>
</template>

<!-- Manual refetch (single group/teacher, triggered outside cron) -->
<template v-else>
<AppIcon
:name="entry.step.ok ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
class="mt-0.5 size-4 shrink-0"
:class="entry.step.ok ? 'text-green-500' : 'text-destructive'"
/>
<div class="min-w-0">
<span class="text-sm font-medium">
{{ STEP_LABELS.manualRefetch }}
</span>
<span class="text-muted-foreground ml-1.5 text-xs">
{{ entry.step.entityType === "group" ? "Група" : "Викладач" }}
#{{ entry.step.entityId }} — {{ entry.step.eventsCount }} подій
</span>
<span
v-if="!entry.step.ok && entry.step.error"
class="text-destructive mt-0.5 block text-xs"
>
{{ entry.step.error }}
</span>
</div>
</template>
</div>
</div>

Expand Down
3 changes: 3 additions & 0 deletions app/composables/useDash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ export const STATUS_LABELS: Record<string, string> = {
export const TRIGGER_LABELS: Record<string, string> = {
cron: "Розклад",
bootstrap: "Запуск",
manual: "Ручний",
}

export const STEP_LABELS: Record<string, string> = {
auditoriums: "Аудиторії",
groups: "Групи",
teachers: "Викладачі",
phantomSkip: "Фантомний скіп",
manualRefetch: "Ручний перезапит",
}

// ── Status variant ─────────────────────────────────────────────────────────────
Expand Down
37 changes: 36 additions & 1 deletion app/queries/dash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,30 @@ export interface StepResult {
error?: string
}

export interface ManualRefetchStep {
entityType: "group" | "teacher"
entityId: number
ok: boolean
eventsCount: number
error?: string
}

export interface SyncSteps {
auditoriums: StepResult
groups: StepResult
teachers: StepResult
/** Present only when the (currently disabled by default) phantom-skip feature skipped groups that run. */
phantomSkip?: { count: number }
/** Present only on trigger="manual" runs. */
manualRefetch?: ManualRefetchStep
}

export interface SyncRun {
id: number
startedAt: string
finishedAt: string | null
status: "running" | "success" | "partial" | "failed"
trigger: "cron" | "bootstrap"
trigger: "cron" | "bootstrap" | "manual"
totalGroups: number
failedGroups: number
removedEvents: number
Expand Down Expand Up @@ -134,3 +146,26 @@ export function dashTableSizesOptions() {
refetchInterval: 5 * 60_000,
})
}

// ── Actions ──────────────────────────────────────────────────────────────────

export interface RefetchResult {
ok: boolean
eventsCount: number
error?: string
}

/** Hides a specific failed group entry from GET /dash/failures. Not reversible manually. */
export function dismissFailure(runId: number, groupId: number) {
return $fetch<ApiResponse<{ dismissed: boolean }>>(
`/dash-api/failures/${runId}/${groupId}/dismiss`,
{ method: "PATCH" }
).then((r) => r.data)
}

/** Triggers an immediate on-demand refetch of one group's schedule, outside the cron cycle. */
export function refetchGroup(groupId: number) {
return $fetch<ApiResponse<RefetchResult>>(`/dash-api/refetch/groups/${groupId}`, {
method: "POST",
}).then((r) => r.data)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mindenit-schedule",
"version": "1.2.2",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading