Add recompute progress bar in the UI header
Build and Deploy / build-and-deploy (push) Successful in 2m2s
Build and Deploy / build-and-deploy (push) Successful in 2m2s
RecomputeService now tracks running/processed/total sources and the last run's new-intersection count via atomics (recompute() is already synchronized to one run at a time, so plain shared state is enough). New GET /api/recompute/status exposes it. The header polls this every second while a recompute is in flight (whether triggered by the button or an already-running scheduled job picked up on page load) and shows a progress bar + "processed / total" counter instead of just a static "Пересчёт..." label. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
316d21341e
commit
6997dfb4e0
+68
-9
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, Route, Routes } from 'react-router-dom';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Watched from './pages/Watched.jsx';
|
||||
@@ -6,22 +6,62 @@ import Settings from './pages/Settings.jsx';
|
||||
import { api } from './api/client.js';
|
||||
|
||||
export default function App() {
|
||||
const [recomputing, setRecomputing] = useState(false);
|
||||
const [progress, setProgress] = useState(null); // { running, processed, total, lastNewIntersections }
|
||||
const [message, setMessage] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
function stopPolling() {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
stopPolling();
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getRecomputeStatus();
|
||||
setProgress(status);
|
||||
if (!status.running) {
|
||||
stopPolling();
|
||||
setMessage(`Пересчёт завершён: ${status.lastNewIntersections} новых пересечений найдено. Обновите страницу, чтобы увидеть результат.`);
|
||||
}
|
||||
} catch {
|
||||
stopPolling();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Pick up an already-running recompute (e.g. the daily/weekly scheduled
|
||||
// job) so the bar shows progress even if this page wasn't the one that
|
||||
// triggered it.
|
||||
useEffect(() => {
|
||||
api.getRecomputeStatus().then((status) => {
|
||||
if (status.running) {
|
||||
setProgress(status);
|
||||
pollStatus();
|
||||
}
|
||||
}).catch(() => {});
|
||||
return stopPolling;
|
||||
}, []);
|
||||
|
||||
async function handleRecompute() {
|
||||
setRecomputing(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.recompute();
|
||||
setMessage('Пересчёт запущен - обновите страницу через некоторое время, чтобы увидеть новые результаты.');
|
||||
setProgress({ running: true, processed: 0, total: 0, lastNewIntersections: 0 });
|
||||
pollStatus();
|
||||
} catch (e) {
|
||||
setMessage('Не удалось запустить пересчёт: ' + e.message);
|
||||
} finally {
|
||||
setRecomputing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const running = progress?.running;
|
||||
const percent = running && progress.total > 0
|
||||
? Math.min(100, Math.round((progress.processed / progress.total) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
@@ -31,10 +71,29 @@ export default function App() {
|
||||
<NavLink to="/watched">Просмотрено</NavLink>
|
||||
<NavLink to="/settings">Настройки</NavLink>
|
||||
</nav>
|
||||
<button className="primary" onClick={handleRecompute} disabled={recomputing}>
|
||||
{recomputing ? 'Запуск...' : 'Пересчитать сейчас'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{running && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
|
||||
{progress.total > 0 ? `${progress.processed} / ${progress.total} (${percent}%)` : 'Запуск...'}
|
||||
</span>
|
||||
)}
|
||||
<button className="primary" onClick={handleRecompute} disabled={running}>
|
||||
{running ? 'Пересчёт...' : 'Пересчитать сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{running && (
|
||||
<div style={{ height: 3, background: 'var(--bg-elevated)' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
width: percent != null ? `${percent}%` : '20%',
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{message && <div style={{ padding: '8px 24px', color: '#9aa0a8' }}>{message}</div>}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
@@ -33,6 +33,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
recompute: () => request('/api/recompute', { method: 'POST' }),
|
||||
getRecomputeStatus: () => request('/api/recompute/status'),
|
||||
|
||||
getIntegrations: () => request('/api/settings/integrations'),
|
||||
getIntegration: (type) => request(`/api/settings/integrations/${type}`),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.recommendarr.controller;
|
||||
|
||||
import com.recommendarr.service.RecomputeService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -9,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/** Manual "Recompute now" button in the UI header. */
|
||||
/** Manual "Recompute now" button in the UI header, with a pollable progress endpoint. */
|
||||
@RestController
|
||||
@RequestMapping("/api/recompute")
|
||||
public class RecomputeController {
|
||||
@@ -28,4 +29,10 @@ public class RecomputeController {
|
||||
CompletableFuture.runAsync(recomputeService::recompute);
|
||||
return ResponseEntity.accepted().body(Map.of("status", "recompute started"));
|
||||
}
|
||||
|
||||
/** Polled by the UI to drive the recompute progress bar. */
|
||||
@GetMapping("/status")
|
||||
public RecomputeService.Progress status() {
|
||||
return recomputeService.progress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,21 @@ public class RecomputeService {
|
||||
private final IntersectionRepository intersectionRepository;
|
||||
private final ScoringConfigService scoringConfigService;
|
||||
|
||||
// Progress tracking for the "Recompute now" button's progress bar - a
|
||||
// single global recompute run at a time (recompute() is synchronized),
|
||||
// so plain shared fields are enough; no per-request state needed.
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final AtomicInteger totalSources = new AtomicInteger(0);
|
||||
private final AtomicInteger processedSources = new AtomicInteger(0);
|
||||
private volatile int lastNewIntersections = 0;
|
||||
|
||||
public record Progress(boolean running, int processed, int total, int lastNewIntersections) {
|
||||
}
|
||||
|
||||
public Progress progress() {
|
||||
return new Progress(running.get(), processedSources.get(), totalSources.get(), lastNewIntersections);
|
||||
}
|
||||
|
||||
public RecomputeService(RadarrClient radarrClient, SonarrClient sonarrClient, TmdbClient tmdbClient,
|
||||
MediaService mediaService, MediaEntityRepository mediaRepository,
|
||||
IntersectionRepository intersectionRepository,
|
||||
@@ -50,6 +67,17 @@ public class RecomputeService {
|
||||
|
||||
public synchronized void recompute() {
|
||||
log.info("Starting recommendation recompute");
|
||||
running.set(true);
|
||||
processedSources.set(0);
|
||||
totalSources.set(0);
|
||||
try {
|
||||
doRecompute();
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void doRecompute() {
|
||||
syncLibraries();
|
||||
|
||||
// Sources = everything ever added to Radarr/Sonarr, whether still
|
||||
@@ -58,11 +86,15 @@ public class RecomputeService {
|
||||
// membership requirement.
|
||||
Set<MediaEntity> sources = new LinkedHashSet<>(
|
||||
mediaRepository.findByStatusIn(List.of(MediaStatus.IN_LIBRARY, MediaStatus.REMOVED)));
|
||||
totalSources.set(sources.size());
|
||||
|
||||
int maxResults = scoringConfigService.get().getMaxResultsPerSource();
|
||||
int newIntersections = 0;
|
||||
for (MediaEntity source : sources) {
|
||||
if (source.getTmdbId() == null || source.getTmdbId() <= 0) continue;
|
||||
if (source.getTmdbId() == null || source.getTmdbId() <= 0) {
|
||||
processedSources.incrementAndGet();
|
||||
continue;
|
||||
}
|
||||
List<TmdbClient.TmdbResult> found = new ArrayList<>();
|
||||
found.addAll(tmdbClient.recommendations(source.getTmdbId(), source.getType()));
|
||||
found.addAll(tmdbClient.similar(source.getTmdbId(), source.getType()));
|
||||
@@ -109,7 +141,9 @@ public class RecomputeService {
|
||||
}
|
||||
count++;
|
||||
}
|
||||
processedSources.incrementAndGet();
|
||||
}
|
||||
lastNewIntersections = newIntersections;
|
||||
log.info("Recompute finished: {} sources processed, {} new intersections recorded",
|
||||
sources.size(), newIntersections);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user