This is an automated email from the ASF dual-hosted git repository.

dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git


The following commit(s) were added to refs/heads/master by this push:
     new af732a11 IGNITE-28655 Add admin maintenance actions (#224)
af732a11 is described below

commit af732a11bebb81f5420f56014e59fd4220c63601
Author: ignitetcbot <[email protected]>
AuthorDate: Sun May 10 14:05:19 2026 +0300

    IGNITE-28655 Add admin maintenance actions (#224)
    
    
    Codex co-authored-by: Dmitriy Pavlov <[email protected]>
---
 conf/branches.json                                 |   2 +
 .../ci/tcbot/conf/LocalFilesBasedConfig.java       |   9 +
 .../tcbot/visa/TcBotTriggerAndSignOffService.java  |  82 +++++--
 .../ci/web/rest/monitoring/CacheMetricsUi.java     |   4 +-
 .../ci/web/rest/monitoring/MonitoringService.java  | 196 ++++++++++++++--
 .../ignite/ci/web/rest/visa/TcBotVisaService.java  |   8 +-
 .../src/main/webapp/js/common-1.7.js               |  37 ++-
 ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js |  32 ++-
 .../src/main/webapp/monitoring.html                | 249 +++++++++++++++++++++
 ignite-tc-helper-web/src/main/webapp/prs.html      |   4 +-
 .../persistence/scheduler/SchedulerModule.java     |   1 +
 .../ignite/tcbot/engine/conf/ITcBotConfig.java     |  11 +
 .../ignite/tcbot/engine/conf/TcBotJsonConfig.java  |  10 +
 .../githubignited/GitHubConnIgnitedImpl.java       |   9 +
 .../org/apache/ignite/ci/github/GitHubUser.java    |  11 +-
 .../scheduler/DirectExecNoWaitScheduler.java       |  12 +
 .../tcbot/persistence/scheduler/IScheduler.java    |  36 +++
 .../scheduler/MaintenanceActionInfo.java           |  50 +++++
 .../scheduler/MaintenanceActionRegistry.java       |  96 ++++++++
 .../tcbot/persistence/scheduler/NamedTask.java     |  60 ++++-
 ...NoWaitScheduler.java => ScheduledTaskInfo.java} |  38 ++--
 .../persistence/scheduler/TcBotScheduler.java      |  27 +++
 22 files changed, 928 insertions(+), 56 deletions(-)

diff --git a/conf/branches.json b/conf/branches.json
index e7a841c6..efbc5da4 100644
--- a/conf/branches.json
+++ b/conf/branches.json
@@ -4,6 +4,8 @@
   // TeamCity group ids whose members can administer sensitive bot settings.
   // Apache Ignite test admins are exposed by TeamCity REST as 
key="IGNITE_COMMITER".
   "botAdminGroups": ["IGNITE_COMMITER"],
+  // Ignite cache names admins may reset from monitoring UI.
+  "resettableCaches": ["testFixMatches", "testFixSourceUpdates"],
   "confidence": 0.995,
   "cleanerConfig": {
     "numOfItemsToDel": 100000,
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
index a7af0d21..2764cbc1 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
@@ -151,6 +151,15 @@ public class LocalFilesBasedConfig implements ITcBotConfig 
{
         return userAdmins == null ? Collections.emptyList() : userAdmins;
     }
 
+    /** {@inheritDoc} */
+    @Override public Collection<String> resettableCaches() {
+        Collection<String> resettableCaches = getConfig().resettableCaches();
+
+        return resettableCaches == null || resettableCaches.isEmpty()
+            ? ITcBotConfig.DEFAULT_RESETTABLE_CACHES
+            : resettableCaches;
+    }
+
     @Override
     public ITrackedBranchesConfig getTrackedBranches() {
         return getConfig();
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
index def5f379..dc760177 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
@@ -766,7 +766,9 @@ public class TcBotTriggerAndSignOffService {
                 visaStatus.prAuthor = author.login();
                 visaStatus.prAuthorAvatarUrl = author.avatarUrl();
 
-                if (!Strings.isNullOrEmpty(author.login()))
+                if (!Strings.isNullOrEmpty(author.htmlUrl()))
+                    visaStatus.prAuthorUrl = author.htmlUrl();
+                else if (!Strings.isNullOrEmpty(author.login()))
                     visaStatus.prAuthorUrl = "https://github.com/"; + 
author.login();
             }
         }
@@ -1262,7 +1264,8 @@ public class TcBotTriggerAndSignOffService {
         AtomicInteger activeBuildLookupCnt = new AtomicInteger();
 
         long stepStart = System.nanoTime();
-        processMonitor.status(processId, "Building the contribution list from 
cached bot data.");
+        processMonitor.status(processId, "Resolving GitHub, JIRA, and TeamCity 
services for server " +
+            srvCodeOrAlias + ".");
 
         IJiraIgnited jiraIntegration = jiraIgnProv.server(srvCodeOrAlias);
 
@@ -1272,14 +1275,17 @@ public class TcBotTriggerAndSignOffService {
         serviceResolveNanos = System.nanoTime() - stepStart;
 
         stepStart = System.nanoTime();
-        processMonitor.status(processId, "Loading pull request details from 
GitHub.");
+        processMonitor.status(processId, "Loading pull request details from 
the GitHub cache.");
         List<PullRequest> prs = gitHubConnIgnited.getPullRequests();
         prsLoadNanos = System.nanoTime() - stepStart;
+        int prsCnt = prs == null ? 0 : prs.size();
+        processMonitor.status(processId, "Loaded " + prsCnt + " open pull 
requests from the GitHub cache.");
 
         stepStart = System.nanoTime();
         processMonitor.status(processId, "Loading JIRA tickets for 
contribution matching.");
         Set<Ticket> tickets = jiraIntegration.getTickets();
         ticketsLoadNanos = System.nanoTime() - stepStart;
+        processMonitor.status(processId, "Loaded " + tickets.size() + " JIRA 
tickets for contribution matching.");
 
         Map<String, Ticket> ticketsByKey = tickets.stream()
             .filter(ticket -> ticket.key != null)
@@ -1289,13 +1295,15 @@ public class TcBotTriggerAndSignOffService {
         IGitHubConfig ghCfg = gitHubConnIgnited.config();
 
         stepStart = System.nanoTime();
+        processMonitor.status(processId, "Resolving default TeamCity build 
type for contribution links.");
         String defBtForTcServ = findDefaultBuildType(srvCodeOrAlias);
         defaultBuildTypeNanos = System.nanoTime() - stepStart;
 
         List<ContributionToCheck> contribsList = new ArrayList<>();
 
         stepStart = System.nanoTime();
-        processMonitor.status(processId, "Matching pull requests, JIRA 
tickets, and TeamCity branches.");
+        processMonitor.status(processId, "Matching " + prsCnt + " pull 
requests with JIRA tickets and TeamCity " +
+            "branches.");
         if (prs != null) {
             prs.forEach(pr -> {
                 ContributionToCheck c = new ContributionToCheck();
@@ -1310,7 +1318,8 @@ public class TcBotTriggerAndSignOffService {
                 GitHubUser user = pr.gitHubUser();
                 if (user != null) {
                     c.prAuthor = user.login();
-                    c.prAuthorUrl = Strings.isNullOrEmpty(user.login()) ? "" : 
"https://github.com/"; + user.login();
+                    c.prAuthorUrl = !Strings.isNullOrEmpty(user.htmlUrl()) ? 
user.htmlUrl()
+                        : Strings.isNullOrEmpty(user.login()) ? "" : 
"https://github.com/"; + user.login();
                     c.prAuthorAvatarUrl = user.avatarUrl();
                 }
                 else {
@@ -1345,7 +1354,12 @@ public class TcBotTriggerAndSignOffService {
                         .findAny()
                         .ifPresent(bName -> c.tcBranchName = bName);
                 prBuildLookupNanos.addAndGet(System.nanoTime() - 
buildLookupStart);
-                prBuildLookupCnt.incrementAndGet();
+                int processed = prBuildLookupCnt.incrementAndGet();
+
+                if (processed % 10 == 0 || processed == prsCnt) {
+                    processMonitor.status(processId, "Matched " + processed + 
" of " + prsCnt +
+                        " pull requests with JIRA tickets and TeamCity 
builds.");
+                }
 
                 contribsList.add(c);
             });
@@ -1353,16 +1367,19 @@ public class TcBotTriggerAndSignOffService {
         prLoopNanos = System.nanoTime() - stepStart;
 
         stepStart = System.nanoTime();
-        processMonitor.status(processId, "Loading repository branches.");
+        processMonitor.status(processId, "Loading repository branches from the 
GitHub cache.");
         List<String> branches = gitHubConnIgnited.getBranches();
         Set<String> branchesSet = new HashSet<>(branches);
         branchesLoadNanos = System.nanoTime() - stepStart;
+        processMonitor.status(processId, "Loaded " + branches.size() + " 
repository branches from the GitHub cache.");
 
         stepStart = System.nanoTime();
         List<Ticket> activeTickets = tickets.stream()
             .filter(ticket -> 
JiraTicketStatusCode.isActiveContribution(ticket.status()))
             .collect(Collectors.toList());
         activeTicketsFilterNanos = System.nanoTime() - stepStart;
+        processMonitor.status(processId, "Checking " + activeTickets.size() +
+            " active JIRA tickets for PR-less contributions.");
 
         stepStart = System.nanoTime();
         activeTickets.forEach(ticket -> {
@@ -1438,7 +1455,7 @@ public class TcBotTriggerAndSignOffService {
                 activeBuildLookupCnt.get());
         }
 
-        processMonitor.status(processId, "The contribution list is ready.");
+        processMonitor.status(processId, "The contribution list is ready: " + 
contribsList.size() + " rows.");
 
         return contribsList;
     }
@@ -1462,16 +1479,29 @@ public class TcBotTriggerAndSignOffService {
     public List<ContributionToCheck> refreshContributionsToCheck(String 
srvCodeOrAlias,
         ITcBotUserCreds credsProv,
         @Nullable Long processId) {
+        processMonitor.status(processId, "Resolving GitHub connection for 
server " + srvCodeOrAlias + ".");
+
         IGitHubConnIgnited gitHubConnIgnited = 
gitHubConnIgnitedProvider.server(srvCodeOrAlias);
+        IGitHubConfig ghCfg = gitHubConnIgnited.config();
+        String gitApiUrl = ghCfg.gitApiUrl();
 
-        processMonitor.status(processId, "Refreshing pull requests from 
GitHub.");
-        gitHubConnIgnited.refreshPullRequests();
+        processMonitor.status(processId, "Refreshing pull requests from GitHub 
API into the bot cache: GET " +
+            gitApiUrl + "pulls?sort=updated&direction=desc (" + 
gitHubRateLimiterSummary(ghCfg) + ").");
+        String prsRefresh = gitHubConnIgnited.refreshPullRequests();
+        processMonitor.status(processId, "GitHub pull request refresh 
finished: " +
+            compactProcessMessage(prsRefresh));
 
-        if (gitHubConnIgnited.config().isPreferBranches()) {
-            processMonitor.status(processId, "Refreshing repository 
branches.");
-            gitHubConnIgnited.refreshBranches();
+        if (ghCfg.isPreferBranches()) {
+            processMonitor.status(processId, "Refreshing repository branches 
from GitHub API into the bot cache: GET " +
+                gitApiUrl + "branches (" + gitHubRateLimiterSummary(ghCfg) + 
").");
+            String branchesRefresh = gitHubConnIgnited.refreshBranches();
+            processMonitor.status(processId, "GitHub branch refresh finished: 
" +
+                compactProcessMessage(branchesRefresh));
         }
+        else
+            processMonitor.status(processId, "Repository branch refresh 
skipped because preferBranches=false.");
 
+        processMonitor.status(processId, "Building contribution table from 
refreshed GitHub/JIRA/TeamCity data.");
         return getContributionsToCheck(srvCodeOrAlias, credsProv, processId);
     }
 
@@ -2508,6 +2538,32 @@ public class TcBotTriggerAndSignOffService {
         return nanosToMillis(System.nanoTime() - startNanos);
     }
 
+    /**
+     * @param msg Process summary returned by an integration refresh.
+     */
+    private static String compactProcessMessage(@Nullable String msg) {
+        if (Strings.isNullOrEmpty(msg))
+            return "done";
+
+        String compact = msg.replace('\r', ' ').replace('\n', ' ').trim();
+
+        if (compact.length() <= 220)
+            return compact;
+
+        return compact.substring(0, 217) + "...";
+    }
+
+    /**
+     * @param cfg GitHub config.
+     */
+    private static String gitHubRateLimiterSummary(IGitHubConfig cfg) {
+        if (cfg.isGitTokenAvailable())
+            return "GitHub rate limiter active with token, up to 5000 
requests/hour";
+
+        return "GitHub rate limiter active without token, up to 60 
requests/hour; configure GitHub token for faster " +
+            "refresh";
+    }
+
     /**
      * Logs slow visa operation.
      */
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
index bca7ebe0..98e5f403 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
@@ -22,10 +22,12 @@ public class CacheMetricsUi {
     public String name;
     public Integer size;
     public Integer parts;
+    public boolean resettable;
 
-    public CacheMetricsUi(String name, int size, int parts) {
+    public CacheMetricsUi(String name, int size, int parts, boolean 
resettable) {
         this.name = name;
         this.size = size;
         this.parts = parts;
+        this.resettable = resettable;
     }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
index 60342226..6a6937c5 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
@@ -28,6 +28,31 @@ import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.security.RolesAllowed;
+import javax.servlet.ServletContext;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ClientErrorException;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
 import org.apache.ignite.Ignite;
 import org.apache.ignite.IgniteCache;
 import org.apache.ignite.cache.CacheMetrics;
@@ -42,24 +67,15 @@ import 
org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
 import org.apache.ignite.tcbot.engine.conf.INotificationChannel;
 import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
 import org.apache.ignite.tcbot.engine.conf.NotificationsConfig;
+import org.apache.ignite.tcbot.engine.process.BotProcessMonitor;
+import org.apache.ignite.tcbot.engine.process.BotProcessStatus;
 import org.apache.ignite.tcbot.notify.IEmailSender;
 import org.apache.ignite.tcbot.notify.ISendEmailConfig;
 import org.apache.ignite.tcbot.notify.ISlackSender;
-
-import javax.annotation.security.RolesAllowed;
-import javax.servlet.ServletContext;
-import javax.ws.rs.*;
-import javax.ws.rs.core.Context;
-import javax.ws.rs.core.MediaType;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.List;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import org.apache.ignite.tcbot.persistence.scheduler.IScheduler;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionInfo;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionRegistry;
+import org.apache.ignite.tcbot.persistence.scheduler.ScheduledTaskInfo;
 
 @Path("monitoring")
 @Produces(MediaType.APPLICATION_JSON)
@@ -121,6 +137,109 @@ public class MonitoringService {
         }).collect(Collectors.toList());
     }
 
+    @GET
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+    @Path("scheduledTasks")
+    public List<ScheduledTaskInfo> getScheduledTasks() {
+        MaintenanceActionRegistry actions = 
instance(MaintenanceActionRegistry.class);
+        BotProcessMonitor process = instance(BotProcessMonitor.class);
+
+        return instance(IScheduler.class).scheduledTasks().stream()
+            .peek(task -> enrichScheduledTask(task, actions, process))
+            .collect(Collectors.toList());
+    }
+
+    @GET
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+    @Path("maintenanceActions")
+    public List<MaintenanceActionInfo> getMaintenanceActions() {
+        IScheduler scheduler = instance(IScheduler.class);
+        MaintenanceActionRegistry actions = 
instance(MaintenanceActionRegistry.class);
+        BotProcessMonitor process = instance(BotProcessMonitor.class);
+        Map<String, ScheduledTaskInfo> scheduled = 
scheduler.scheduledTasks().stream()
+            .peek(task -> enrichScheduledTask(task, actions, process))
+            .collect(Collectors.toMap(task -> task.name, task -> task, (first, 
second) -> first));
+
+        return actions.actions().stream()
+            .peek(action -> {
+                ScheduledTaskInfo task = scheduled.get(action.name);
+
+                if (task == null)
+                    return;
+
+                action.status = task.status;
+                action.processStatus = task.processStatus;
+                action.processId = task.processId;
+                action.canStartNow = task.canStartNow;
+            })
+            .collect(Collectors.toList());
+    }
+
+    @POST
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+    @Path("maintenanceActions/start")
+    public SimpleResult startMaintenanceAction(@FormParam("name") String name,
+        @QueryParam("processId") Long processId) {
+        if (Strings.isNullOrEmpty(name))
+            throw new BadRequestException("Action name is required");
+
+        MaintenanceActionRegistry actions = 
instance(MaintenanceActionRegistry.class);
+        IScheduler scheduler = instance(IScheduler.class);
+        BotProcessMonitor process = instance(BotProcessMonitor.class);
+
+        if (!actions.hasAction(name)) {
+            process.fail(processId, "Maintenance action is not registered: " + 
name);
+
+            throw new NotFoundException("Maintenance action is not registered: 
" + name);
+        }
+
+        process.start(processId, "maintenanceAction", "Maintenance action 
request accepted: " + name);
+
+        boolean accepted = scheduler.runNamedNow(name, () -> {
+            try {
+                process.status(processId, "Running maintenance action: " + 
name);
+
+                String result = actions.run(name);
+
+                process.finish(processId, result);
+            }
+            catch (Exception e) {
+                process.fail(processId, e);
+                throw new RuntimeException(e);
+            }
+        }, processId);
+
+        if (!accepted) {
+            process.fail(processId, "Maintenance action is already queued or 
running: " + name);
+
+            throw new ClientErrorException("Maintenance action is already 
queued or running: " + name,
+                Response.Status.CONFLICT);
+        }
+
+        return new SimpleResult("Maintenance action start requested: " + name);
+    }
+
+    /**
+     * @param task Scheduled task.
+     * @param actions Maintenance actions.
+     * @param process Bot process monitor.
+     */
+    private void enrichScheduledTask(ScheduledTaskInfo task, 
MaintenanceActionRegistry actions,
+        BotProcessMonitor process) {
+        if (task.processId != null) {
+            BotProcessStatus status = process.status(task.processId);
+
+            if (status.id != null && !Strings.isNullOrEmpty(status.status)) {
+                task.processStatus = status.status;
+
+                if (status.isRunning())
+                    task.status = status.status;
+            }
+        }
+
+        task.canStartNow = actions.hasAction(task.name) && task.canStartNow;
+    }
+
     @GET
     @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("appLogSummaryLink")
@@ -454,6 +573,7 @@ public class MonitoringService {
     @Path("cacheMetrics")
     public List<CacheMetricsUi> getCacheStat() {
         Ignite ignite = instance(Ignite.class);
+        Set<String> resettableCaches = resettableCaches();
 
         final Collection<String> strings = ignite.cacheNames();
 
@@ -477,11 +597,55 @@ public class MonitoringService {
 
             Affinity<Object> affinity = ignite.affinity(next);
 
-            res.add(new CacheMetricsUi(next, size, affinity.partitions()));
+            res.add(new CacheMetricsUi(next, size, affinity.partitions(), 
resettableCaches.contains(next)));
         }
         return res;
     }
 
+    @POST
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+    @Path("resetCache")
+    public SimpleResult resetCache(@FormParam("name") String name, 
@QueryParam("processId") Long processId) {
+        BotProcessMonitor process = instance(BotProcessMonitor.class);
+        Set<String> resettableCaches = resettableCaches();
+
+        process.start(processId, "resetCache", "Cache reset request accepted: 
" + name);
+
+        if (!resettableCaches.contains(name)) {
+            process.fail(processId, "Cache reset is not allowed for: " + name);
+
+            throw new BadRequestException("Cache reset is not allowed for: " + 
name);
+        }
+
+        Ignite ignite = instance(Ignite.class);
+        IgniteCache<?, ?> cache = ignite.cache(name);
+
+        if (cache == null) {
+            process.fail(processId, "Cache not found: " + name);
+
+            throw new NotFoundException("Cache not found: " + name);
+        }
+
+        int size = cache.size();
+
+        process.status(processId, "Clearing cache: " + name);
+
+        cache.clear();
+
+        String result = "Cache reset: " + name + ", cleared entries: " + size;
+
+        process.finish(processId, result);
+
+        return new SimpleResult(result);
+    }
+
+    /**
+     * @return Cache names admins may reset from monitoring UI.
+     */
+    private Set<String> resettableCaches() {
+        return Set.copyOf(instance(ITcBotConfig.class).resettableCaches());
+    }
+
     @GET
     @Path("requests")
     public List<RequestStat> getRequestStats() {
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
index 2c6b6a17..b3e378e0 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
@@ -157,15 +157,19 @@ public class TcBotVisaService {
         TcBotApplicationContext appCtx = 
CtxListener.getApplicationContext(ctx);
         BotProcessMonitor process = 
appCtx.getInstance(BotProcessMonitor.class);
 
-        process.start(processId, "refreshContributions", "Sending PR refresh 
request to the bot REST API.");
+        process.start(processId, "refreshContributions", "PR refresh REST 
endpoint accepted the request.");
 
         try {
+            process.status(processId, "Checking TeamCity credentials for 
server " + srvCode + ".");
+
             
appCtx.getInstance(ITeamcityIgnitedProvider.class).checkAccess(srvCode, 
credsProv);
 
+            process.status(processId, "TeamCity credentials accepted. Starting 
GitHub cache refresh.");
+
             List<ContributionToCheck> res = 
appCtx.getInstance(TcBotTriggerAndSignOffService.class)
                 .refreshContributionsToCheck(srvCode, credsProv, processId);
 
-            process.finish(processId, "count=" + res.size());
+            process.finish(processId, "PR refresh finished. Loaded " + 
res.size() + " contributions.");
 
             return res;
         }
diff --git a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js 
b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
index 3893be2e..1b9e17cd 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
@@ -121,9 +121,28 @@ function startBotProcessPolling(processId, onStatus, 
options) {
 
     var opts = options || {};
     var lastStatus = null;
+    var lastReportAt = 0;
     var stopped = false;
     var timer;
 
+    function reportStatus(status) {
+        var now = Date.now();
+        var statusText = status && isDefinedAndFilled(status.status) ? 
status.status : "";
+        var repeated = statusText === lastStatus;
+
+        if (repeated && (!opts.repeatMs || now - lastReportAt < opts.repeatMs))
+            return;
+
+        if (repeated && typeof opts.repeatText === "function") {
+            status = $.extend({}, status);
+            status.status = opts.repeatText(status);
+        }
+
+        lastStatus = statusText;
+        lastReportAt = now;
+        onStatus(status);
+    }
+
     function poll() {
         if (stopped)
             return;
@@ -138,13 +157,23 @@ function startBotProcessPolling(processId, onStatus, 
options) {
                 if (!isDefinedAndFilled(status.kind) && opts.skipUnknown !== 
false)
                     return;
 
-                if (status.status !== lastStatus) {
-                    lastStatus = status.status;
-                    onStatus(status);
-                }
+                reportStatus(status);
 
                 if (status.running === false)
                     stop();
+            },
+            error: function (jqXHR, textStatus, errorThrown) {
+                if (stopped || opts.reportErrors !== true || textStatus === 
"abort")
+                    return;
+
+                reportStatus({
+                    id: processId,
+                    kind: "process-status",
+                    running: true,
+                    status: "Unable to read bot process status: " +
+                        (jqXHR.status ? "HTTP " + jqXHR.status + " " : "") +
+                        (errorThrown || jqXHR.statusText || textStatus || 
"unknown error")
+                });
             }
         });
     }
diff --git a/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js 
b/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
index 28045cd5..075f6933 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
@@ -183,7 +183,17 @@ function requestTableForServer(srvId, element) {
                 loadGithubResolutionForContributions(srvId);
                 fillBranchAutocompleteList(result, srvId);
                 setAutocompleteFilter();
+            },
+        error: function (jqXHR, exception) {
+            let table = $("#" + tableId);
+
+            if (table.length > 0) {
+                table.find("thead").html("<tr 
class='ui-widget-header'><th>Failed to load contributions</th></tr>");
+                table.append("<tbody><tr><td>" + escapeHtml(jqXHR.responseText 
|| "Request failed") + "</td></tr></tbody>");
             }
+
+            showErrInLoadStatus(jqXHR, exception);
+        }
     });
 }
 
@@ -230,10 +240,30 @@ function refreshContributionsNow(srvId) {
 
     openCenteredDialog(dialog, actionDialogOptions("Refresh pull requests", 
{}));
 
+    appendStage("Created browser-side process id " + processId + ".");
     appendStage("Sending PR refresh request to the bot REST API.");
+    appendStage("Waiting for the backend to register the refresh process.");
 
     stopProcessPolling = startBotProcessPolling(processId, function 
(processStatus) {
-        appendStage(botProcessStatusText(processStatus));
+        let statusText = botProcessStatusText(processStatus);
+
+        if (!isDefinedAndFilled(processStatus.kind))
+            statusText += " The refresh HTTP request may still be entering the 
backend endpoint.";
+        else if (processStatus.running !== false)
+            setActionStatus(dialog, statusText);
+
+        appendStage(statusText);
+    }, {
+        intervalMs: 1000,
+        skipUnknown: false,
+        reportErrors: true,
+        repeatMs: 7000,
+        repeatText: function (processStatus) {
+            if (!isDefinedAndFilled(processStatus.kind))
+                return "Still waiting for the bot to register process " + 
processId + ".";
+
+            return "Still running: " + botProcessStatusText(processStatus);
+        }
     });
 
     xhr = $.ajax({
diff --git a/ignite-tc-helper-web/src/main/webapp/monitoring.html 
b/ignite-tc-helper-web/src/main/webapp/monitoring.html
index 7f326e47..68c5bdb1 100644
--- a/ignite-tc-helper-web/src/main/webapp/monitoring.html
+++ b/ignite-tc-helper-web/src/main/webapp/monitoring.html
@@ -94,6 +94,9 @@
                 error: showErrInLoadStatus
             });
 
+            loadScheduledTasksData();
+            loadMaintenanceActionsData();
+
             $.ajax({
                 url: "rest/monitoring/appLogSummaryLink",
                 success: showAppLogSummaryLink,
@@ -117,6 +120,36 @@
         loadAiPromptsData();
     }
 
+    function loadScheduledTasksData() {
+        if (!monitoringAdmin)
+            return;
+
+        $.ajax({
+            url: "rest/monitoring/scheduledTasks",
+            success: function(result) {
+                $("#loadStatus").html("");
+
+                showScheduledTasks(result);
+            },
+            error: showErrInLoadStatus
+        });
+    }
+
+    function loadMaintenanceActionsData() {
+        if (!monitoringAdmin)
+            return;
+
+        $.ajax({
+            url: "rest/monitoring/maintenanceActions",
+            success: function(result) {
+                $("#loadStatus").html("");
+
+                showMaintenanceActions(result);
+            },
+            error: showErrInLoadStatus
+        });
+    }
+
     function loadAiPromptsData() {
         $.ajax({
             url: "rest/monitoring/aiPrompts",
@@ -163,6 +196,78 @@
         $("#tasks").html(res);
     }
 
+    function showScheduledTasks(result) {
+        var res = "<table class='stat'>" ;
+
+        res += "<tr>";
+        res += "<th>Name</th>";
+        res += "<th>Status</th>";
+        res += "<th>Last finished</th>";
+        res += "<th>Quiet period</th>";
+        res += "<th>Runnable</th>";
+        res += "<th>Actions</th>";
+        res += "</tr>";
+
+        for (var i = 0; i < result.length; i++) {
+            var task = result[i];
+            var status = task.processStatus || task.status;
+
+            res += "<tr>";
+            res += "<td>" + escapeHtml(task.name) + "</td>";
+            res += "<td>" + escapeHtml(status) + "</td>";
+            res += "<td>" + escapeHtml(formatTs(task.lastFinishedTs)) + 
"</td>";
+            res += "<td>" + escapeHtml(formatDuration(task.quietPeriodMs)) + 
"</td>";
+            res += "<td>" + escapeHtml(task.runnableAvailable) + "</td>";
+            res += "<td>";
+
+            if (task.canStartNow) {
+                res += "<button onclick='startMaintenanceAction(" + 
JSON.stringify(task.name) +
+                    ")'>Start now</button>";
+            }
+
+            res += "</td>";
+            res += "</tr>";
+        }
+
+        res += "</table>";
+
+        $("#scheduledTasks").html(res);
+    }
+
+    function showMaintenanceActions(result) {
+        var res = "<table class='stat'>" ;
+
+        res += "<tr>";
+        res += "<th>Name</th>";
+        res += "<th>Description</th>";
+        res += "<th>Status</th>";
+        res += "<th>Actions</th>";
+        res += "</tr>";
+
+        for (var i = 0; i < result.length; i++) {
+            var action = result[i];
+            var status = action.processStatus || action.status || "";
+
+            res += "<tr>";
+            res += "<td>" + escapeHtml(action.name) + "</td>";
+            res += "<td>" + escapeHtml(action.description) + "</td>";
+            res += "<td>" + escapeHtml(status) + "</td>";
+            res += "<td>";
+
+            if (action.canStartNow !== false) {
+                res += "<button onclick='startMaintenanceAction(" + 
JSON.stringify(action.name) +
+                    ")'>Start now</button>";
+            }
+
+            res += "</td>";
+            res += "</tr>";
+        }
+
+        res += "</table>";
+
+        $("#maintenanceActions").html(res);
+    }
+
     function showAppLogSummaryLink(result) {
         var logUrl = "monitoring-log.html?startTs=" + 
encodeURIComponent(result.startTs)
             + "&endTs=0"
@@ -235,6 +340,8 @@
         res += "<th>Name</th>";
         res += "<th>Size</th>";
         res += "<th>Parts</th>";
+        if (monitoringAdmin)
+            res += "<th>Actions</th>";
         res += "</tr>";
         for (var i = 0; i < result.length; i++) {
             var inv = result[i];
@@ -242,6 +349,14 @@
             res += "<td>" + escapeHtml(inv.name) + "</td>";
             res += "<td>" + escapeHtml(inv.size) + "</td>";
             res += "<td>" + escapeHtml(inv.parts) + "</td>";
+            if (monitoringAdmin) {
+                res += "<td>";
+                if (inv.resettable) {
+                    res += "<button onclick='resetCache(" + 
JSON.stringify(inv.name) +
+                        ")'>Reset</button>";
+                }
+                res += "</td>";
+            }
             res += "</tr>";
         }
         $("#caches").html(res);
@@ -306,6 +421,132 @@
         });
     }
 
+    function resetCache(name) {
+        if (!monitoringAdmin)
+            return false;
+
+        confirmAdminAction("Reset cache",
+            "Clear all entries from cache \"" + name + "\"? This action is 
admin-only.",
+            function() {
+                var processId = createBotProcessId("resetCache");
+                var dialog = openAdminProcessDialog("Reset cache",
+                    "Resetting cache \"" + name + "\"...");
+                var stopPolling = pollAdminProcess(dialog, processId, 
loadData);
+
+                $.ajax({
+                    url: "rest/monitoring/resetCache?processId=" + 
encodeURIComponent(processId),
+                    method: "post",
+                    data: {name: name},
+                    success: function(result) {
+                        appendActionStage(dialog, result.result || "Ok");
+                    },
+                    error: function(jqXHR, exception) {
+                        if (stopPolling)
+                            stopPolling();
+
+                        showActionError(dialog, jqXHR.responseText || 
exception);
+                        dialog.dialog("option", "buttons", {"Ok": function() { 
$(this).dialog("close"); }});
+                    }
+                });
+            });
+
+        return false;
+    }
+
+    function startMaintenanceAction(name) {
+        if (!monitoringAdmin)
+            return false;
+
+        confirmAdminAction("Start maintenance action",
+            "Start maintenance action \"" + name + "\" now? This action is 
admin-only.",
+            function() {
+                var processId = createBotProcessId("maintenanceAction");
+                var dialog = openAdminProcessDialog("Maintenance action",
+                    "Starting \"" + name + "\"...");
+                var stopPolling = pollAdminProcess(dialog, processId, 
function() {
+                    loadScheduledTasksData();
+                    loadMaintenanceActionsData();
+                });
+
+                $.ajax({
+                    url: "rest/monitoring/maintenanceActions/start?processId=" 
+ encodeURIComponent(processId),
+                    method: "post",
+                    data: {name: name},
+                    success: function(result) {
+                        appendActionStage(dialog, result.result || "Started");
+                    },
+                    error: function(jqXHR, exception) {
+                        if (stopPolling)
+                            stopPolling();
+
+                        showActionError(dialog, jqXHR.responseText || 
exception);
+                        dialog.dialog("option", "buttons", {"Ok": function() { 
$(this).dialog("close"); }});
+                    }
+                });
+            });
+
+        return false;
+    }
+
+    function confirmAdminAction(title, text, onConfirm) {
+        var dialog = ensureActionDialog("adminConfirmDialog", title);
+
+        dialog.html(actionStatusHtml(text));
+
+        openActionDialog(dialog, title, {
+                "Confirm": function() {
+                    $(this).dialog("close");
+                    onConfirm();
+                },
+                "Cancel": function() {
+                    $(this).dialog("close");
+                }
+        });
+    }
+
+    function openAdminProcessDialog(title, status) {
+        var dialog = ensureActionDialog("adminProcessDialog", title);
+
+        dialog.html(actionStatusHtml(status) + actionStagesHtml(false) + 
actionErrorHtml());
+        openActionDialog(dialog, title, {});
+        appendActionStage(dialog, "Sending request to the bot REST API.");
+
+        return dialog;
+    }
+
+    function pollAdminProcess(dialog, processId, onComplete) {
+        return startBotProcessPolling(processId, function(status) {
+            appendActionStage(dialog, botProcessStatusText(status));
+
+            if (status.running === false) {
+                dialog.dialog("option", "buttons", {"Ok": function() { 
$(this).dialog("close"); }});
+
+                if (onComplete)
+                    onComplete();
+            }
+        }, {skipUnknown: false});
+    }
+
+    function formatTs(ts) {
+        if (!ts || ts <= 0)
+            return "";
+
+        return new Date(ts).toLocaleString();
+    }
+
+    function formatDuration(ms) {
+        if (!ms || ms <= 0)
+            return "";
+
+        if (ms < 60000)
+            return Math.round(ms / 1000) + "s";
+
+        if (ms < 3600000)
+            return Math.round(ms / 60000) + "m";
+
+        return Math.round(ms / 3600000) + "h";
+    }
+
     function testSlackNotification() {
         if (!monitoringAdmin)
             return false;
@@ -359,6 +600,14 @@
     <div id="appLogSummary" style="font-family: monospace"></div>
     <div id="tasks" style="font-family: monospace"></div>
     <br>
+
+    Scheduled Tasks:
+    <div id="scheduledTasks" style="font-family: monospace"></div>
+    <br>
+
+    Maintenance Actions:
+    <div id="maintenanceActions" style="font-family: monospace"></div>
+    <br>
 </div>
 
 <div id="userAdminBlock" style="display: none">
diff --git a/ignite-tc-helper-web/src/main/webapp/prs.html 
b/ignite-tc-helper-web/src/main/webapp/prs.html
index e299ed53..14e65e7f 100644
--- a/ignite-tc-helper-web/src/main/webapp/prs.html
+++ b/ignite-tc-helper-web/src/main/webapp/prs.html
@@ -17,9 +17,9 @@
     <link rel="stylesheet" 
href="https://use.fontawesome.com/releases/v5.4.2/css/all.css";
           
integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns"
 crossorigin="anonymous">
 
-    <script src="js/common-1.7.js?v=20260509-6"></script>
+    <script src="js/common-1.7.js?v=20260510-1"></script>
     <script src="js/testfails-2.3.js?v=20260509-22"></script>
-    <script src="js/prs-1.3.js?v=20260510-2"></script>
+    <script src="js/prs-1.3.js?v=20260510-4"></script>
 
     <style>
 
diff --git 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
index bc792ab2..c3652491 100644
--- 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
+++ 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
@@ -24,5 +24,6 @@ public class SchedulerModule extends AbstractModule {
     /** {@inheritDoc} */
     @Override protected void configure() {
         bind(IScheduler.class).to(TcBotScheduler.class).in(Scopes.SINGLETON);
+        bind(MaintenanceActionRegistry.class).in(Scopes.SINGLETON);
     }
 }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
index c9473a5c..8bbb26b9 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
@@ -23,6 +23,7 @@ import 
org.apache.ignite.tcbot.common.conf.IDataSourcesConfigSupplier;
 
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 
 /**
  * Teamcity Bot configuration access interface.
@@ -40,6 +41,9 @@ public interface ITcBotConfig extends 
IDataSourcesConfigSupplier {
     /** Default TeamCity group id whose members are allowed to administer bot 
settings. */
     String DEFAULT_BOT_ADMIN_GROUP = "IGNITE_COMMITER";
 
+    /** Caches safe to reset from monitoring UI by default. */
+    List<String> DEFAULT_RESETTABLE_CACHES = List.of("testFixMatches", 
"testFixSourceUpdates");
+
     /** */
     String primaryServerCode();
 
@@ -84,6 +88,13 @@ public interface ITcBotConfig extends 
IDataSourcesConfigSupplier {
         return Collections.emptyList();
     }
 
+    /**
+     * @return Ignite cache names that admins may reset from monitoring UI.
+     */
+    default Collection<String> resettableCaches() {
+        return DEFAULT_RESETTABLE_CACHES;
+    }
+
     /**
      * @return notification settings config.
      */
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
index e53a6ae7..06e334cd 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
@@ -53,6 +53,9 @@ public class TcBotJsonConfig implements 
ITrackedBranchesConfig {
     /** Bot user logins allowed to manage users. */
     @Nullable private List<String> userAdmins;
 
+    /** Ignite cache names admins may reset from monitoring UI. */
+    @Nullable private List<String> resettableCaches;
+
     /** Additional list Servers to be used for validation of PRs, but not for 
tracking any branches. */
     private List<TcServerConfig> tcServers = new ArrayList<>();
 
@@ -135,6 +138,13 @@ public class TcBotJsonConfig implements 
ITrackedBranchesConfig {
         return userAdmins;
     }
 
+    /**
+     * @return Ignite cache names admins may reset from monitoring UI.
+     */
+    @Nullable public List<String> resettableCaches() {
+        return resettableCaches;
+    }
+
     public Optional<TcServerConfig> getTcConfig(String code) {
         return tcServers.stream().filter(s -> Objects.equals(code, 
s.getCode())).findAny();
     }
diff --git 
a/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
 
b/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
index db163c2d..117dd11f 100644
--- 
a/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
+++ 
b/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
@@ -45,6 +45,7 @@ import org.apache.ignite.ci.github.GitHubIssueComment;
 import org.apache.ignite.ci.github.GitHubUser;
 import org.apache.ignite.ci.github.PullRequest;
 import org.apache.ignite.tcbot.common.conf.IGitHubConfig;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionRegistry;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -68,6 +69,9 @@ class GitHubConnIgnitedImpl implements IGitHubConnIgnited {
     /** Scheduler. */
     @Inject IScheduler scheduler;
 
+    /** Admin maintenance actions. */
+    @Inject MaintenanceActionRegistry maintenanceActions;
+
     /** Server ID mask for cache Entries. */
     private int srvIdMaskHigh;
 
@@ -97,6 +101,11 @@ class GitHubConnIgnitedImpl implements IGitHubConnIgnited {
         prCache = 
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_PR));
         branchCache = 
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_BRANCHES));
         userCache = 
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_USERS));
+
+        maintenanceActions.register(taskName("actualizePrs"), "Refresh GitHub 
pull requests for " + srvCode,
+            this::refreshPullRequests);
+        maintenanceActions.register(taskName("actualizeBranches"), "Refresh 
GitHub branches for " + srvCode,
+            this::refreshBranches);
     }
 
     /** {@inheritDoc} */
diff --git 
a/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java 
b/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
index 4f4e30e2..ac0e0faf 100644
--- a/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
+++ b/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
@@ -25,6 +25,7 @@ import java.util.Objects;
 public class GitHubUser {
     @SerializedName("login") private String login;
     @SerializedName("url") private String url;
+    @SerializedName("html_url") private String htmlUrl;
     @SerializedName("avatar_url") private String avatarUrl;
     @SerializedName("email") private String email;
     /*See full example in prsList.json */
@@ -43,6 +44,13 @@ public class GitHubUser {
         return url;
     }
 
+    /**
+     * @return GitHub web user URL.
+     */
+    public String htmlUrl() {
+        return htmlUrl;
+    }
+
     /**
      * @return Avatar url.
      */
@@ -66,12 +74,13 @@ public class GitHubUser {
         GitHubUser user = (GitHubUser)o;
         return Objects.equals(login, user.login) &&
             Objects.equals(url, user.url) &&
+            Objects.equals(htmlUrl, user.htmlUrl) &&
             Objects.equals(avatarUrl, user.avatarUrl) &&
             Objects.equals(email, user.email);
     }
 
     /** {@inheritDoc} */
     @Override public int hashCode() {
-        return Objects.hash(login, url, avatarUrl, email);
+        return Objects.hash(login, url, htmlUrl, avatarUrl, email);
     }
 }
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
index 0b22bd7d..ece632f2 100644
--- 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
@@ -32,6 +32,18 @@ public class DirectExecNoWaitScheduler implements IScheduler 
{
         cmd.run();
     }
 
+    /** {@inheritDoc} */
+    @Override public boolean runNamedNow(String fullName, Runnable cmd) {
+        return runNamedNow(fullName, cmd, null);
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean runNamedNow(String fullName, Runnable cmd, Long 
processId) {
+        cmd.run();
+
+        return true;
+    }
+
     /** {@inheritDoc} */
     @Override public void stop() {
 
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
index c9e46ac3..4f545720 100644
--- 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
@@ -18,6 +18,8 @@ package org.apache.ignite.tcbot.persistence.scheduler;
 
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
+import java.util.Collections;
+import java.util.List;
 
 /**
  * Scheduler is a way to run background syncs between Ignite DB and REST 
services.
@@ -36,5 +38,39 @@ public interface IScheduler {
 
     public void sheduleNamed(String fullName, Runnable cmd, long queitPeriod, 
TimeUnit unit);
 
+    /**
+     * Runs named action as soon as possible using scheduler executor.
+     *
+     * @param fullName Named task name.
+     * @param cmd Task body.
+     * @return {@code true} if the action was accepted, {@code false} if the 
same named task is already running or
+     * queued.
+     */
+    public default boolean runNamedNow(String fullName, Runnable cmd) {
+        return runNamedNow(fullName, cmd, null);
+    }
+
+    /**
+     * Runs named action as soon as possible using scheduler executor.
+     *
+     * @param fullName Named task name.
+     * @param cmd Task body.
+     * @param processId Optional process monitor id associated with this 
manual run.
+     * @return {@code true} if the action was accepted, {@code false} if the 
same named task is already running or
+     * queued.
+     */
+    public default boolean runNamedNow(String fullName, Runnable cmd, Long 
processId) {
+        sheduleNamed(fullName, cmd, 0, TimeUnit.MILLISECONDS);
+
+        return true;
+    }
+
+    /**
+     * @return User-visible named task state.
+     */
+    public default List<ScheduledTaskInfo> scheduledTasks() {
+        return Collections.emptyList();
+    }
+
     public void stop();
 }
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
new file mode 100644
index 00000000..16d652e6
--- /dev/null
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.ignite.tcbot.persistence.scheduler;
+
+/**
+ * User-visible maintenance action registered by a service.
+ */
+@SuppressWarnings("PublicField")
+public class MaintenanceActionInfo {
+    /** Action name. */
+    public String name;
+
+    /** Action description. */
+    public String description;
+
+    /** Matching scheduler status, if the action has a named scheduler task. */
+    public String status;
+
+    /** Current user-visible bot process status, if this action is running 
through a monitored manual action. */
+    public String processStatus;
+
+    /** Bot process monitor id associated with the current manual run. */
+    public Long processId;
+
+    /** Action can be started by admin now. */
+    public boolean canStartNow = true;
+
+    /**
+     * @param name Action name.
+     * @param description Action description.
+     */
+    public MaintenanceActionInfo(String name, String description) {
+        this.name = name;
+        this.description = description;
+    }
+}
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
new file mode 100644
index 00000000..f1e46cdc
--- /dev/null
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.ignite.tcbot.persistence.scheduler;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/**
+ * Registry of admin-triggerable maintenance actions owned by concrete 
services.
+ */
+public class MaintenanceActionRegistry {
+    /** Actions by name. */
+    private final ConcurrentMap<String, Action> actions = new 
ConcurrentHashMap<>();
+
+    /**
+     * @param name Action name.
+     * @param description Action description.
+     * @param action Action body.
+     */
+    public void register(String name, String description, Callable<String> 
action) {
+        actions.put(name, new Action(name, description, action));
+    }
+
+    /**
+     * @return Action list.
+     */
+    public List<MaintenanceActionInfo> actions() {
+        return actions.values().stream()
+            .map(action -> new MaintenanceActionInfo(action.name, 
action.description))
+            .sorted(Comparator.comparing(info -> info.name))
+            .collect(Collectors.toList());
+    }
+
+    /**
+     * @param name Action name.
+     * @return {@code true} if action is registered.
+     */
+    public boolean hasAction(@Nullable String name) {
+        return name != null && actions.containsKey(name);
+    }
+
+    /**
+     * @param name Action name.
+     * @return Action result.
+     */
+    public String run(String name) throws Exception {
+        Action action = actions.get(name);
+
+        if (action == null)
+            throw new IllegalArgumentException("Maintenance action is not 
registered: " + name);
+
+        return action.action.call();
+    }
+
+    /** Registered action. */
+    private static class Action {
+        /** Action name. */
+        private final String name;
+
+        /** Action description. */
+        private final String description;
+
+        /** Action body. */
+        private final Callable<String> action;
+
+        /**
+         * @param name Action name.
+         * @param description Action description.
+         * @param action Action body.
+         */
+        private Action(String name, String description, Callable<String> 
action) {
+            this.name = name;
+            this.description = description;
+            this.action = action;
+        }
+    }
+}
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
index 8c1ef609..29e09ebc 100644
--- 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
@@ -37,6 +37,12 @@ class NamedTask {
     @GuardedBy("lock")
     private volatile long resValidityMs = 0;
 
+    @GuardedBy("lock")
+    private volatile boolean forceRun;
+
+    @GuardedBy("lock")
+    private volatile Long processId;
+
     enum Status {
         CREATED, RUNNING, COMPLETED;
     }
@@ -77,6 +83,28 @@ class NamedTask {
 
     }
 
+    /**
+     * @param cmd Task body.
+     * @return {@code true} if task was queued, {@code false} if it is already 
queued or running.
+     */
+    public boolean scheduleNow(@Nonnull Runnable cmd, Long processId) {
+        long writeLockStamp = lock.writeLock();
+
+        try {
+            if (status == Status.RUNNING || this.cmd != null)
+                return false;
+
+            this.cmd = cmd;
+            forceRun = true;
+            this.processId = processId;
+
+            return true;
+        }
+        finally {
+            lock.unlock(writeLockStamp);
+        }
+    }
+
     public Runnable runIfNeeded() throws Exception {
         long optReadStamp = lock.tryOptimisticRead();
         boolean canSkip = canSkipStartNow();
@@ -108,8 +136,10 @@ class NamedTask {
             this.cmd = null;
 
             // because here lock is not upgraded from read lock cmd may come 
here with null
-            if (cmd != null)
+            if (cmd != null) {
+                forceRun = false;
                 status = Status.RUNNING;
+            }
         }
         finally {
             lock.unlock(writeLockStamp);
@@ -126,6 +156,7 @@ class NamedTask {
             try {
                 lastFinishedTs = System.currentTimeMillis();
                 status = Status.COMPLETED;
+                processId = null;
             }
             finally {
                 lock.unlock(writeLockStamp2);
@@ -135,7 +166,34 @@ class NamedTask {
         return cmd;
     }
 
+    /**
+     * @return User-visible task snapshot.
+     */
+    public ScheduledTaskInfo info() {
+        long readStamp = lock.readLock();
+
+        try {
+            ScheduledTaskInfo res = new ScheduledTaskInfo();
+
+            res.name = name;
+            res.status = status.name();
+            res.lastFinishedTs = lastFinishedTs;
+            res.quietPeriodMs = resValidityMs;
+            res.runnableAvailable = cmd != null;
+            res.canStartNow = status != Status.RUNNING && cmd == null;
+            res.processId = processId;
+
+            return res;
+        }
+        finally {
+            lock.unlockRead(readStamp);
+        }
+    }
+
     public boolean canSkipStartNow() {
+        if (forceRun)
+            return false;
+
         boolean canSkip = false;
         if (status == Status.RUNNING)
             canSkip = true;
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
similarity index 52%
copy from 
tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
copy to 
tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
index 0b22bd7d..72bbd03f 100644
--- 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
@@ -16,24 +16,32 @@
  */
 package org.apache.ignite.tcbot.persistence.scheduler;
 
-import java.util.concurrent.TimeUnit;
-
 /**
- * Scheduler which never waits. Can be used for tests.
+ * User-visible state of a named scheduled task.
  */
-public class DirectExecNoWaitScheduler implements IScheduler {
-    /** {@inheritDoc} */
-    @Override public void invokeLater(Runnable cmd, long delay, TimeUnit unit) 
{
-        cmd.run();
-    }
+@SuppressWarnings("PublicField")
+public class ScheduledTaskInfo {
+    /** Task name. */
+    public String name;
+
+    /** Task status. */
+    public String status;
+
+    /** Last finished timestamp. */
+    public long lastFinishedTs;
+
+    /** Fresh result validity period. */
+    public long quietPeriodMs;
+
+    /** Task has runnable action registered. */
+    public boolean runnableAvailable;
 
-    /** {@inheritDoc} */
-    @Override public void sheduleNamed(String fullName, Runnable cmd, long 
queitPeriod, TimeUnit unit) {
-        cmd.run();
-    }
+    /** Task can be started by admin now. */
+    public boolean canStartNow;
 
-    /** {@inheritDoc} */
-    @Override public void stop() {
+    /** Bot process monitor id associated with the currently queued/running 
manual action. */
+    public Long processId;
 
-    }
+    /** Current user-visible bot process status, if this task was started 
through a monitored manual action. */
+    public String processStatus;
 }
diff --git 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
index 495cb2d2..24c5fcd9 100644
--- 
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
+++ 
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
@@ -19,6 +19,7 @@ package org.apache.ignite.tcbot.persistence.scheduler;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
@@ -29,6 +30,7 @@ import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 import org.apache.ignite.tcbot.common.interceptor.MonitoredTask;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -77,6 +79,23 @@ class TcBotScheduler implements IScheduler {
         }
     }
 
+    /** {@inheritDoc} */
+    @Override public boolean runNamedNow(String fullName, Runnable cmd) {
+        return runNamedNow(fullName, cmd, null);
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean runNamedNow(String fullName, Runnable cmd, Long 
processId) {
+        NamedTask task = namedTasks.computeIfAbsent(fullName, NamedTask::new);
+
+        if (!task.scheduleNow(cmd, processId))
+            return false;
+
+        service().execute(() -> checkNamedTasks("Manual"));
+
+        return true;
+    }
+
     /**
      * @param threadNme Runner name to be used in display.
      */
@@ -100,6 +119,14 @@ class TcBotScheduler implements IScheduler {
         return "Finished " + run.get() + " task(s) " + (problems.isEmpty() ? 
"" : (", exceptions: " + problems.toString()));
     }
 
+    /** {@inheritDoc} */
+    @Override public List<ScheduledTaskInfo> scheduledTasks() {
+        return namedTasks.values().stream()
+            .map(NamedTask::info)
+            .sorted(Comparator.comparing(info -> info.name))
+            .collect(Collectors.toList());
+    }
+
     /** {@inheritDoc} */
     @Override public void stop() {
         if (executorSvc != null) {

Reply via email to