add a cooldown on the force refresh action (#1556)

This commit is contained in:
Athou
2024-09-20 14:03:20 +02:00
parent 0d75688ec8
commit 19c8db8b31
20 changed files with 121 additions and 22 deletions

View File

@@ -220,6 +220,7 @@ export interface ServerInfo {
websocketEnabled: boolean websocketEnabled: boolean
websocketPingInterval: number websocketPingInterval: number
treeReloadInterval: number treeReloadInterval: number
forceRefreshCooldownDuration: number
} }
export interface SharingSettings { export interface SharingSettings {
@@ -287,6 +288,7 @@ export interface UserModel {
created: number created: number
lastLogin?: number lastLogin?: number
admin: boolean admin: boolean
lastForceRefresh?: number
} }
export interface AdminSaveUserRequest { export interface AdminSaveUserRequest {

View File

@@ -2,14 +2,10 @@ import { Trans } from "@lingui/macro"
import { Tooltip } from "@mantine/core" import { Tooltip } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import dayjs from "dayjs" import dayjs from "dayjs"
import { useEffect, useState } from "react" import { useNow } from "hooks/useNow"
export function RelativeDate(props: { date: Date | number | undefined }) { export function RelativeDate(props: { date: Date | number | undefined }) {
const [now, setNow] = useState(new Date()) const now = useNow(60 * 1000)
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
return () => clearInterval(interval)
}, [])
if (!props.date) return <Trans>N/A</Trans> if (!props.date) return <Trans>N/A</Trans>
const date = dayjs(props.date) const date = dayjs(props.date)

View File

@@ -15,6 +15,9 @@ import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetr
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import type { ViewMode } from "app/types" import type { ViewMode } from "app/types"
import { setViewMode } from "app/user/slice" import { setViewMode } from "app/user/slice"
import { reloadProfile } from "app/user/thunks"
import dayjs from "dayjs"
import { useNow } from "hooks/useNow"
import { type ReactNode, useState } from "react" import { type ReactNode, useState } from "react"
import { import {
TbChartLine, TbChartLine,
@@ -92,12 +95,19 @@ const viewModeData: ViewModeControlItem[] = [
export function ProfileMenu(props: ProfileMenuProps) { export function ProfileMenu(props: ProfileMenuProps) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const now = useNow()
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const admin = useAppSelector(state => state.user.profile?.admin) const admin = useAppSelector(state => state.user.profile?.admin)
const viewMode = useAppSelector(state => state.user.localSettings.viewMode) const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { colorScheme, setColorScheme } = useMantineColorScheme() const { colorScheme, setColorScheme } = useMantineColorScheme()
const nextAvailableForceRefresh = profile?.lastForceRefresh
? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
: now.getTime()
const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
const logout = () => { const logout = () => {
window.location.href = "logout" window.location.href = "logout"
} }
@@ -118,18 +128,24 @@ export function ProfileMenu(props: ProfileMenuProps) {
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<TbWorldDownload size={iconSize} />} leftSection={<TbWorldDownload size={iconSize} />}
onClick={async () => disabled={!forceRefreshEnabled}
await client.feed.refreshAll().then(() => { onClick={async () => {
showNotification({ setOpened(false)
message: <Trans>Your feeds have been queued for refresh.</Trans>,
color: "green", await client.feed.refreshAll()
autoClose: 1000,
}) // reload profile to update last force refresh timestamp
setOpened(false) await dispatch(reloadProfile())
showNotification({
message: <Trans>Your feeds have been queued for refresh.</Trans>,
color: "green",
autoClose: 1000,
}) })
} }}
> >
<Trans>Fetch all my feeds now</Trans> <Trans>Fetch all my feeds now</Trans>
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
</Menu.Item> </Menu.Item>
<Divider /> <Divider />

View File

@@ -0,0 +1,10 @@
import { useEffect, useState } from "react"
export const useNow = (interval = 1000): Date => {
const [time, setTime] = useState(new Date())
useEffect(() => {
const t = setInterval(() => setTime(new Date()), interval)
return () => clearInterval(t)
}, [interval])
return time
}

View File

@@ -6,11 +6,13 @@ import "react-contexify/ReactContexify.css"
import { App } from "App" import { App } from "App"
import { store } from "app/store" import { store } from "app/store"
import dayjs from "dayjs" import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime" import relativeTime from "dayjs/plugin/relativeTime"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { Provider } from "react-redux" import { Provider } from "react-redux"
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
dayjs.extend(duration)
const root = document.getElementById("root") const root = document.getElementById("root")
root && root &&

View File

@@ -413,6 +413,23 @@ endif::add-copy-button-to-env-var[]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]] |link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`500MS` |`500MS`
a| [[commafeed-server_commafeed-feed-refresh-force-refresh-cooldown-duration]] [.property-path]##`commafeed.feed-refresh.force-refresh-cooldown-duration`##
[.description]
--
Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds. 0 to disable.
ifdef::add-copy-button-to-env-var[]
Environment variable: env_var_with_copy_button:+++COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION+++[]
endif::add-copy-button-to-env-var[]
ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`1M`
h|[[commafeed-server_section_commafeed-database]] [.section-name.section-level0]##Database settings## h|[[commafeed-server_section_commafeed-database]] [.section-name.section-level0]##Database settings##
h|Type h|Type

View File

@@ -209,6 +209,14 @@ public interface CommaFeedConfiguration {
*/ */
@WithDefault("500ms") @WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout(); Duration filteringExpressionEvaluationTimeout();
/**
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds.
*
* 0 to disable.
*/
@WithDefault("1m")
Duration forceRefreshCooldownDuration();
} }
interface Database { interface Database {

View File

@@ -18,7 +18,8 @@ public class JacksonCustomizer implements ObjectMapperCustomizer {
objectMapper.registerModule(new JavaTimeModule()); objectMapper.registerModule(new JavaTimeModule());
// read and write instants as milliseconds instead of nanoseconds // read and write instants as milliseconds instead of nanoseconds
objectMapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false) objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
// add support for serializing metrics // add support for serializing metrics

View File

@@ -52,4 +52,7 @@ public class User extends AbstractModel {
@Column @Column
private Instant recoverPasswordTokenDate; private Instant recoverPasswordTokenDate;
@Column
private Instant lastForceRefresh;
} }

View File

@@ -98,12 +98,19 @@ public class FeedSubscriptionService {
} }
} }
public void refreshAll(User user) { public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
Instant lastForceRefresh = user.getLastForceRefresh();
if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
throw new ForceFeedRefreshTooSoonException();
}
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
for (FeedSubscription sub : subs) { for (FeedSubscription sub : subs) {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
feedRefreshEngine.refreshImmediately(feed); feedRefreshEngine.refreshImmediately(feed);
} }
user.setLastForceRefresh(Instant.now());
} }
public void refreshAllUpForRefresh(User user) { public void refreshAllUpForRefresh(User user) {
@@ -130,4 +137,11 @@ public class FeedSubscriptionService {
} }
} }
@SuppressWarnings("serial")
public static class ForceFeedRefreshTooSoonException extends Exception {
private ForceFeedRefreshTooSoonException() {
super();
}
}
} }

View File

@@ -43,4 +43,7 @@ public class ServerInfo implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED) @Schema(requiredMode = RequiredMode.REQUIRED)
private long treeReloadInterval; private long treeReloadInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long forceRefreshCooldownDuration;
} }

View File

@@ -41,4 +41,7 @@ public class UserModel implements Serializable {
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED) @Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin; private boolean admin;
@Schema(description = "user last force refresh", type = "number")
private Instant lastForceRefresh;
} }

View File

@@ -10,6 +10,7 @@ import java.util.Objects;
import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils; import org.apache.commons.lang3.SystemUtils;
import org.apache.hc.core5.http.HttpStatus;
import org.jboss.resteasy.reactive.Cache; import org.jboss.resteasy.reactive.Cache;
import org.jboss.resteasy.reactive.RestForm; import org.jboss.resteasy.reactive.RestForm;
@@ -40,6 +41,7 @@ import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterEx
import com.commafeed.backend.service.FeedEntryService; import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService; import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.FeedSubscriptionService; import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.backend.service.FeedSubscriptionService.ForceFeedRefreshTooSoonException;
import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo; import com.commafeed.frontend.model.FeedInfo;
@@ -276,8 +278,13 @@ public class FeedREST {
@Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue") @Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue")
public Response queueAllForRefresh() { public Response queueAllForRefresh() {
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
feedSubscriptionService.refreshAll(user); try {
return Response.ok().build(); feedSubscriptionService.refreshAll(user);
return Response.ok().build();
} catch (ForceFeedRefreshTooSoonException e) {
return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build();
}
} }
@Path("/refresh") @Path("/refresh")

View File

@@ -61,6 +61,7 @@ public class ServerREST {
infos.setWebsocketEnabled(config.websocket().enabled()); infos.setWebsocketEnabled(config.websocket().enabled());
infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis()); infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis());
infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis()); infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis());
infos.setForceRefreshCooldownDuration(config.feedRefresh().forceRefreshCooldownDuration().toMillis());
return Response.ok(infos).build(); return Response.ok(infos).build();
} }

View File

@@ -212,6 +212,7 @@ public class UserREST {
userModel.setEmail(user.getEmail()); userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled()); userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey()); userModel.setApiKey(user.getApiKey());
userModel.setLastForceRefresh(user.getLastForceRefresh());
for (UserRole role : userRoleDAO.findAll(user)) { for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) { if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true); userModel.setAdmin(true);

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="lastForceRefresh" author="athou">
<addColumn tableName="USERS">
<column name="lastForceRefresh" type="${timestamp_type}" />
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -33,5 +33,6 @@
<include file="changelogs/db.changelog-4.4.xml" /> <include file="changelogs/db.changelog-4.4.xml" />
<include file="changelogs/db.changelog-5.1.xml" /> <include file="changelogs/db.changelog-5.1.xml" />
<include file="changelogs/db.changelog-5.2.xml" /> <include file="changelogs/db.changelog-5.2.xml" />
<include file="changelogs/db.changelog-5.3.xml" />
</databaseChangeLog> </databaseChangeLog>

View File

@@ -125,7 +125,7 @@ public abstract class BaseIT {
.as(Entries.class); .as(Entries.class);
} }
protected void forceRefreshAllFeeds() { protected int forceRefreshAllFeeds() {
RestAssured.given().get("rest/feed/refreshAll").then().statusCode(HttpStatus.SC_OK); return RestAssured.given().get("rest/feed/refreshAll").then().extract().statusCode();
} }
} }

View File

@@ -183,11 +183,13 @@ class FeedIT extends BaseIT {
// mariadb/mysql timestamp precision is 1 second // mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); Instant threshold = Instant.now().minus(Duration.ofSeconds(1));
forceRefreshAllFeeds(); Assertions.assertEquals(HttpStatus.SC_OK, forceRefreshAllFeeds());
Awaitility.await() Awaitility.await()
.atMost(Duration.ofSeconds(15)) .atMost(Duration.ofSeconds(15))
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold)); .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
Assertions.assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, forceRefreshAllFeeds());
} }
} }

View File

@@ -21,6 +21,7 @@ class ServerIT extends BaseIT {
Assertions.assertTrue(serverInfos.isWebsocketEnabled()); Assertions.assertTrue(serverInfos.isWebsocketEnabled());
Assertions.assertEquals(900000, serverInfos.getWebsocketPingInterval()); Assertions.assertEquals(900000, serverInfos.getWebsocketPingInterval());
Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval()); Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval());
Assertions.assertEquals(60000, serverInfos.getForceRefreshCooldownDuration());
} }
} }