mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
add a cooldown on the force refresh action (#1556)
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
10
commafeed-client/src/hooks/useNow.ts
Normal file
10
commafeed-client/src/hooks/useNow.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -52,4 +52,7 @@ public class User extends AbstractModel {
|
|||||||
|
|
||||||
@Column
|
@Column
|
||||||
private Instant recoverPasswordTokenDate;
|
private Instant recoverPasswordTokenDate;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Instant lastForceRefresh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user