Refactor plugin reconciliation for dependency mechanism (#5900)

#### What type of PR is this?

/kind improvement
/area core
/area plugin

#### What this PR does / why we need it:

This PR wholly refactors plugin reconciliation to implement dependency mechanism.

Currently,
- If we disable plugin which has dependents, the plugin must wait for dependents to be disabled.
- If we enable plugin which has dependencies , the plugin must wait for dependencies to be enabled.
- If we upgrade plugin which has dependents, the plugin must request dependents to be unloaded. After the plugin is unloaded, the plugin must cancel unload request for dependents.

#### Which issue(s) this PR fixes:

Fixes #5872 

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
优化被依赖的插件的升级,启用和禁用
```
pull/5994/head^2
John Niang 2024-05-27 16:16:56 +08:00 committed by GitHub
parent 769b19c23c
commit 5df51bb715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 993 additions and 407 deletions

View File

@ -14089,8 +14089,6 @@
"Condition": {
"required": [
"lastTransitionTime",
"message",
"reason",
"status",
"type"
],
@ -16944,6 +16942,7 @@
"PENDING",
"STARTING",
"CREATED",
"DISABLING",
"DISABLED",
"RESOLVED",
"STARTED",

View File

@ -142,6 +142,7 @@ public class Plugin extends AbstractExtension {
PENDING,
STARTING,
CREATED,
DISABLING,
DISABLED,
RESOLVED,
STARTED,

View File

@ -51,13 +51,15 @@ public class Condition {
* Human-readable message indicating details about last transition.
* This may be an empty string.
*/
@Schema(requiredMode = REQUIRED, maxLength = 32768)
private String message;
@Schema(maxLength = 32768)
@Builder.Default
private String message = "";
/**
* Unique, one-word, CamelCase reason for the condition's last transition.
*/
@Schema(requiredMode = REQUIRED, maxLength = 1024,
@Schema(maxLength = 1024,
pattern = "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$")
private String reason;
@Builder.Default
private String reason = "";
}

View File

@ -55,13 +55,33 @@ public class ConditionList extends AbstractCollection<Condition> {
* @param condition item to add
*/
public boolean addAndEvictFIFO(@NonNull Condition condition, int evictThreshold) {
boolean result = this.addFirst(condition);
var current = getCondition(condition.getType());
if (current != null) {
// do not update last transition time if status is not changed
if (Objects.equals(condition.getStatus(), current.getStatus())) {
condition.setLastTransitionTime(current.getLastTransitionTime());
}
}
conditions.remove(current);
conditions.addFirst(condition);
while (conditions.size() > evictThreshold) {
removeLast();
}
return result;
return true;
}
private Condition getCondition(String type) {
for (Condition condition : conditions) {
if (condition.getType().equals(type)) {
return condition;
}
}
return null;
}
public void remove(Condition condition) {
conditions.remove(condition);
}

View File

@ -284,51 +284,8 @@ public class PluginEndpoint implements CustomEndpoint {
return request.bodyToMono(RunningStateRequest.class)
.flatMap(runningState -> {
var enable = runningState.isEnable();
var updatedPlugin = Mono.defer(() -> client.get(Plugin.class, name))
.flatMap(plugin -> {
if (!Objects.equals(enable, plugin.getSpec().getEnabled())) {
plugin.getSpec().setEnabled(enable);
log.debug("Updating plugin {} state to {}", name, enable);
return client.update(plugin);
}
log.debug("Checking plugin {} state, no need to update", name);
return Mono.just(plugin);
});
var async = runningState.isAsync();
if (!async) {
// if we want to wait the state of plugin to be updated
updatedPlugin = updatedPlugin
.flatMap(plugin -> {
var phase = plugin.statusNonNull().getPhase();
if (enable) {
// if we request to enable the plugin
if (!(Plugin.Phase.STARTED.equals(phase)
|| Plugin.Phase.FAILED.equals(phase))) {
return Mono.error(UnexpectedPluginStateException::new);
}
} else {
// if we request to disable the plugin
if (Plugin.Phase.STARTED.equals(phase)) {
return Mono.error(UnexpectedPluginStateException::new);
}
}
return Mono.just(plugin);
})
.retryWhen(
Retry.backoff(10, Duration.ofMillis(100))
.filter(UnexpectedPluginStateException.class::isInstance)
.doBeforeRetry(signal ->
log.debug("Waiting for plugin {} to meet expected state", name)
)
)
.doOnSuccess(plugin -> {
log.info("Plugin {} met expected state {}",
name, plugin.statusNonNull().getPhase());
});
}
return updatedPlugin;
return pluginService.changeState(name, enable, !async);
})
.flatMap(plugin -> ServerResponse.ok().bodyValue(plugin));
}
@ -883,7 +840,4 @@ public class PluginEndpoint implements CustomEndpoint {
}
}
private static class UnexpectedPluginStateException extends IllegalStateException {
}
}

View File

@ -6,13 +6,12 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
import static run.halo.app.plugin.PluginConst.PLUGIN_PATH;
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
import static run.halo.app.plugin.PluginConst.RUNTIME_MODE_ANNO;
import static run.halo.app.plugin.PluginConst.REQUEST_TO_UNLOAD_LABEL;
import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting;
import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
import static run.halo.app.plugin.PluginUtils.generateFileName;
import static run.halo.app.plugin.PluginUtils.isDevelopmentMode;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
@ -24,17 +23,17 @@ import java.nio.file.Paths;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginDependency;
import org.pf4j.PluginState;
import org.pf4j.PluginStateEvent;
import org.pf4j.PluginStateListener;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import org.springframework.core.io.DefaultResourceLoader;
@ -57,9 +56,8 @@ import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionList;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.JsonParseException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.PluginConst;
@ -77,8 +75,12 @@ import run.halo.app.plugin.SpringPluginManager;
@Component
public class PluginReconciler implements Reconciler<Request> {
private static final String FINALIZER_NAME = "plugin-protection";
private static final String DEPENDENTS_ANNO_KEY = "plugin.halo.run/dependents-snapshot";
private static final Set<String> UNUSED_ANNOTATIONS =
Set.of("plugin.halo.run/dependents-snapshot");
private final ExtensionClient client;
private final SpringPluginManager pluginManager;
private final PluginProperties pluginProperties;
@ -91,9 +93,6 @@ public class PluginReconciler implements Reconciler<Request> {
this.pluginManager = pluginManager;
this.pluginProperties = pluginProperties;
this.clock = Clock.systemUTC();
this.pluginManager.addPluginStateListener(new PluginStartedListener());
this.pluginManager.addPluginStateListener(new PluginStoppedListener());
}
/**
@ -124,47 +123,69 @@ public class PluginReconciler implements Reconciler<Request> {
return Result.doNotRetry();
}
addFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME));
plugin.statusNonNull().setPhase(Plugin.Phase.PENDING);
removeUnusedAnnotations(plugin);
// Prepare
try {
resolveLoadLocation(plugin);
loadOrReload(plugin);
createOrUpdateSetting(plugin);
createOrUpdateReverseProxy(plugin);
resolveStaticResources(plugin);
if (requestToEnable(plugin)) {
// Start
enablePlugin(plugin);
} else {
// stop the plugin and disable it
disablePlugin(plugin);
}
} catch (Throwable t) {
// populate condition
var condition = Condition.builder()
.type(PluginState.FAILED.toString())
.reason("UnexpectedState")
.message(t.getMessage())
.status(ConditionStatus.FALSE)
.lastTransitionTime(clock.instant())
.build();
var status = plugin.statusNonNull();
nullSafeConditions(status).addAndEvictFIFO(condition);
status.setPhase(Plugin.Phase.FAILED);
throw t;
} finally {
syncPluginState(plugin);
client.update(plugin);
var status = plugin.getStatus();
if (status == null) {
status = new Plugin.PluginStatus();
plugin.setStatus(status);
}
// reset phase to pending
status.setPhase(Plugin.Phase.PENDING);
// init condition list if not exists
if (status.getConditions() == null) {
status.setConditions(new ConditionList());
}
return Result.doNotRetry();
var steps = new LinkedList<Supplier<Result>>();
steps.add(() -> resolveLoadLocation(plugin));
steps.add(() -> loadOrReload(plugin));
steps.add(() -> createOrUpdateSetting(plugin));
steps.add(() -> createOrUpdateReverseProxy(plugin));
steps.add(() -> resolveStaticResources(plugin));
if (requestToEnable(plugin)) {
steps.add(() -> enablePlugin(plugin));
} else {
steps.add(() -> disablePlugin(plugin));
}
Result result = null;
try {
for (var step : steps) {
result = step.get();
if (result != null) {
break;
}
}
return result;
} catch (Exception e) {
status.getConditions().addAndEvictFIFO(Condition.builder()
.type(ConditionType.READY)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.SYSTEM_ERROR)
.message(e.getMessage())
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.UNKNOWN);
throw e;
} finally {
var pw = pluginManager.getPlugin(plugin.getMetadata().getName());
if (pw != null) {
status.setLastProbeState(pw.getPluginState());
}
client.update(plugin);
}
})
.orElseGet(Result::doNotRetry);
}
private void removeUnusedAnnotations(Plugin plugin) {
var annotations = plugin.getMetadata().getAnnotations();
if (annotations != null) {
UNUSED_ANNOTATIONS.forEach(annotations::remove);
}
}
private boolean checkDependents(Plugin plugin) {
var pluginId = plugin.getMetadata().getName();
var dependents = pluginManager.getDependents(pluginId);
@ -173,17 +194,20 @@ public class PluginReconciler implements Reconciler<Request> {
}
var status = plugin.statusNonNull();
var condition = Condition.builder()
.type(PluginState.FAILED.toString())
.reason("DependentsExist")
.type(ConditionType.PROGRESSING)
.status(ConditionStatus.UNKNOWN)
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_DELETED)
.message(
"The plugin has dependents %s, please delete them first."
.formatted(dependents.stream().map(PluginWrapper::getPluginId).toList())
)
.status(ConditionStatus.FALSE)
.lastTransitionTime(clock.instant())
.build();
nullSafeConditions(status).addAndEvictFIFO(condition);
status.setPhase(Plugin.Phase.FAILED);
var conditions = nullSafeConditions(status);
removeConditionBy(conditions, ConditionType.INITIALIZED);
removeConditionBy(conditions, ConditionType.READY);
conditions.addAndEvictFIFO(condition);
status.setPhase(Plugin.Phase.UNKNOWN);
return false;
}
@ -197,6 +221,14 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
private static String requestToUnload(Plugin plugin) {
var labels = plugin.getMetadata().getLabels();
if (labels == null) {
return null;
}
return labels.get(REQUEST_TO_UNLOAD_LABEL);
}
private static boolean requestToReload(Plugin plugin) {
var annotations = plugin.getMetadata().getAnnotations();
return annotations != null && annotations.get(RELOAD_ANNO) != null;
@ -209,7 +241,6 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
private void cleanupResources(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var reverseProxyName = buildReverseProxyName(pluginName);
@ -241,90 +272,121 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
private void enablePlugin(Plugin plugin) {
private Result enablePlugin(Plugin plugin) {
// start the plugin
var pluginName = plugin.getMetadata().getName();
log.info("Starting plugin {}", pluginName);
plugin.statusNonNull().setPhase(Plugin.Phase.STARTING);
var pluginState = pluginManager.startPlugin(pluginName);
if (!PluginState.STARTED.equals(pluginState)) {
throw new IllegalStateException("Failed to start plugin " + pluginName);
var status = plugin.getStatus();
status.setPhase(Plugin.Phase.STARTING);
// check if the parent plugin is started
var pw = pluginManager.getPlugin(pluginName);
var unstartedDependencies = pw.getDescriptor().getDependencies()
.stream()
.filter(pd -> {
if (pd.isOptional()) {
return false;
}
var parent = pluginManager.getPlugin(pd.getPluginId());
return parent == null || !PluginState.STARTED.equals(parent.getPluginState());
})
.map(PluginDependency::getPluginId)
.toList();
var conditions = status.getConditions();
if (!CollectionUtils.isEmpty(unstartedDependencies)) {
removeConditionBy(conditions, ConditionType.READY);
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.PROGRESSING)
.status(ConditionStatus.UNKNOWN)
.reason(ConditionReason.WAIT_FOR_DEPENDENCIES_STARTED)
.message("Wait for parent plugins " + unstartedDependencies + " to be started")
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.UNKNOWN);
return Result.requeue(Duration.ofSeconds(1));
}
var dependents = getAndRemoveDependents(plugin);
log.info("Starting dependents {} for plugin {}", dependents, pluginName);
dependents.stream()
.sorted(Comparator.reverseOrder())
.forEach(dependent -> {
if (pluginManager.getPlugin(dependent) != null) {
pluginManager.startPlugin(dependent);
}
});
log.info("Started dependents {} for plugin {}", dependents, pluginName);
try {
var pluginState = pluginManager.startPlugin(pluginName);
if (!PluginState.STARTED.equals(pluginState)) {
throw new IllegalStateException("""
Failed to start plugin %s(%s).\
""".formatted(pluginName, pluginState));
}
} catch (Exception e) {
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.READY)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.START_ERROR)
.message(e.getMessage())
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.FAILED);
return Result.doNotRetry();
}
plugin.statusNonNull().setLastStartTime(clock.instant());
var condition = Condition.builder()
.type(PluginState.STARTED.toString())
.reason(PluginState.STARTED.toString())
removeConditionBy(conditions, ConditionType.PROGRESSING);
status.setLastStartTime(clock.instant());
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.READY)
.status(ConditionStatus.TRUE)
.reason(ConditionReason.STARTED)
.message("Started successfully")
.lastTransitionTime(clock.instant())
.status(ConditionStatus.TRUE)
.build();
nullSafeConditions(plugin.statusNonNull()).addAndEvictFIFO(condition);
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
.build());
status.setPhase(Plugin.Phase.STARTED);
log.info("Started plugin {}", pluginName);
return null;
}
private List<String> getAndRemoveDependents(Plugin plugin) {
private Result disablePlugin(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var annotations = plugin.getMetadata().getAnnotations();
if (annotations == null) {
return List.of();
}
var dependentsAnno = annotations.remove(DEPENDENTS_ANNO_KEY);
List<String> dependents = List.of();
if (StringUtils.isNotBlank(dependentsAnno)) {
try {
dependents = JsonUtils.jsonToObject(dependentsAnno, new TypeReference<>() {
});
} catch (JsonParseException ignored) {
log.error("Failed to parse dependents annotation {} for plugin {}",
dependentsAnno, pluginName);
// Keep going to start the plugin.
}
}
return dependents;
}
private void setDependents(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var annotations = plugin.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
plugin.getMetadata().setAnnotations(annotations);
}
if (!annotations.containsKey(DEPENDENTS_ANNO_KEY)) {
// get dependents
var status = plugin.getStatus();
if (pluginManager.getPlugin(pluginName) != null) {
// check if the plugin has children
var dependents = pluginManager.getDependents(pluginName)
.stream()
.filter(pluginWrapper ->
Objects.equals(PluginState.STARTED, pluginWrapper.getPluginState())
)
.filter(pw -> PluginState.STARTED.equals(pw.getPluginState()))
.map(PluginWrapper::getPluginId)
.toList();
annotations.put(DEPENDENTS_ANNO_KEY, JsonUtils.objectToJson(dependents));
}
}
private void disablePlugin(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
if (pluginManager.getPlugin(pluginName) != null) {
setDependents(plugin);
pluginManager.disablePlugin(pluginName);
var conditions = status.getConditions();
if (!CollectionUtils.isEmpty(dependents)) {
removeConditionBy(conditions, ConditionType.READY);
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.PROGRESSING)
.status(ConditionStatus.UNKNOWN)
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_DISABLED)
.message("Wait for children plugins " + dependents + " to be disabled")
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.DISABLING);
return Result.requeue(Duration.ofSeconds(1));
}
try {
pluginManager.disablePlugin(pluginName);
} catch (Exception e) {
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.READY)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.DISABLE_ERROR)
.message(e.getMessage())
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.FAILED);
return Result.doNotRetry();
}
}
var conditions = plugin.getStatus().getConditions();
removeConditionBy(conditions, ConditionType.PROGRESSING);
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.READY)
.status(ConditionStatus.TRUE)
.reason(ConditionReason.DISABLED)
.lastTransitionTime(clock.instant())
.build());
plugin.statusNonNull().setPhase(Plugin.Phase.DISABLED);
return null;
}
private static boolean requestToEnable(Plugin plugin) {
@ -332,7 +394,7 @@ public class PluginReconciler implements Reconciler<Request> {
return enabled != null && enabled;
}
private void resolveStaticResources(Plugin plugin) {
private Result resolveStaticResources(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var pluginVersion = plugin.getSpec().getVersion();
if (isDevelopmentMode(plugin)) {
@ -386,44 +448,121 @@ public class PluginReconciler implements Reconciler<Request> {
.toString();
status.setStylesheet(stylesheet);
}
return null;
}
private void loadOrReload(Plugin plugin) {
private Result loadOrReload(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var p = pluginManager.getPlugin(pluginName);
var conditions = plugin.getStatus().getConditions();
var requestToUnloadBy = requestToUnload(plugin);
var requestToUnload = requestToUnloadBy != null;
var notFullyLoaded = p != null && pluginManager.getUnresolvedPlugins().contains(p);
var alreadyLoaded = p != null && pluginManager.getResolvedPlugins().contains(p);
var requestToReload = requestToReload(plugin);
if (requestToReload) {
if (p != null) {
var loadLocation = plugin.getStatus().getLoadLocation();
setDependents(plugin);
var unloaded = pluginManager.reloadPlugin(pluginName, Paths.get(loadLocation));
if (!unloaded) {
throw new PluginRuntimeException("Failed to reload plugin " + pluginName);
// TODO Check load location
var shouldUnload = requestToUnload || requestToReload || notFullyLoaded;
if (shouldUnload) {
// check if the plugin is already loaded or not fully loaded.
if (alreadyLoaded || notFullyLoaded) {
// get all dependencies
var dependents = requestToUnloadChildren(pluginName);
if (!CollectionUtils.isEmpty(dependents)) {
removeConditionBy(conditions, ConditionType.READY);
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.PROGRESSING)
.status(ConditionStatus.UNKNOWN)
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_UNLOADED)
.message("Wait for children plugins " + dependents + "to be unloaded")
.lastTransitionTime(clock.instant())
.build());
plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN);
// wait for children plugins unloaded
// retry after 1 second
return Result.requeue(Duration.ofSeconds(1));
}
p = pluginManager.getPlugin(pluginName);
// unload the plugin exactly
pluginManager.unloadPlugin(pluginName);
removeConditionBy(conditions, ConditionType.INITIALIZED);
removeConditionBy(conditions, ConditionType.PROGRESSING);
removeConditionBy(conditions, ConditionType.READY);
cancelUnloadRequest(pluginName);
p = null;
}
// ensure removing the reload annotation after the plugin is unloaded
if (requestToUnload) {
// skip loading and wait for removing the annotation by other plugins.
var status = plugin.getStatus();
status.getConditions().addAndEvictFIFO(Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.REQUEST_TO_UNLOAD)
.message("Request to unload by " + requestToUnloadBy)
.lastTransitionTime(clock.instant())
.build());
return Result.doNotRetry();
}
if (requestToReload) {
removeRequestToReload(plugin);
}
// ensure removing the reload annotation after the plugin is reloaded
removeRequestToReload(plugin);
}
if (p != null && pluginManager.getUnresolvedPlugins().contains(p)) {
pluginManager.unloadPlugin(pluginName);
p = null;
// check dependencies before loading
var unresolvedParentPlugins = plugin.getSpec().getPluginDependencies().keySet()
.stream()
.filter(dependency -> {
var parentPlugin = pluginManager.getPlugin(dependency);
return parentPlugin == null
|| pluginManager.getUnresolvedPlugins().contains(parentPlugin);
})
.sorted()
.toList();
if (!unresolvedParentPlugins.isEmpty()) {
// requeue if the parent plugin is not loaded yet.
removeConditionBy(conditions, ConditionType.INITIALIZED);
removeConditionBy(conditions, ConditionType.READY);
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.PROGRESSING)
.status(ConditionStatus.UNKNOWN)
.reason(ConditionReason.WAIT_FOR_DEPENDENCIES_LOADED)
.message("Wait for parent plugins " + unresolvedParentPlugins + " to be loaded")
.lastTransitionTime(clock.instant())
.build());
plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN);
return Result.requeue(Duration.ofSeconds(1));
}
if (p == null) {
var loadLocation = plugin.getStatus().getLoadLocation();
log.info("Loading plugin {} from {}", pluginName, loadLocation);
pluginManager.loadPlugin(Paths.get(loadLocation));
log.info("Loaded plugin {} from {}", pluginName, loadLocation);
}
conditions.addAndEvictFIFO(Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.TRUE)
.reason(ConditionReason.LOADED)
.lastTransitionTime(clock.instant())
.build());
plugin.getStatus().setPhase(Plugin.Phase.RESOLVED);
return null;
}
private void createOrUpdateSetting(Plugin plugin) {
private Result createOrUpdateSetting(Plugin plugin) {
log.info("Initializing setting and config map for plugin {}",
plugin.getMetadata().getName());
var settingName = plugin.getSpec().getSettingName();
if (StringUtils.isBlank(settingName)) {
// do nothing if no setting name provided.
return;
return null;
}
var pluginName = plugin.getMetadata().getName();
@ -454,7 +593,7 @@ public class PluginReconciler implements Reconciler<Request> {
// create default config map
var configMapName = plugin.getSpec().getConfigMapName();
if (StringUtils.isBlank(configMapName)) {
return;
return null;
}
var defaultConfigMap = SettingUtils.populateDefaultConfig(setting, configMapName);
@ -469,9 +608,10 @@ public class PluginReconciler implements Reconciler<Request> {
client.update(configMap);
}, () -> client.create(defaultConfigMap));
log.info("Initialized config map {} for plugin {}", configMapName, pluginName);
return null;
}
private void resolveLoadLocation(Plugin plugin) {
private Result resolveLoadLocation(Plugin plugin) {
log.debug("Resolving load location for plugin {}", plugin.getMetadata().getName());
// populate load location from annotations
@ -481,24 +621,46 @@ public class PluginReconciler implements Reconciler<Request> {
var status = plugin.statusNonNull();
if (isDevelopmentMode(plugin)) {
if (!isInDevEnvironment()) {
throw new IllegalStateException(String.format("""
Cannot run the plugin %s with dev mode in non-development environment.\
""", pluginName));
status.getConditions().addAndEvictFIFO(Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.INVALID_RUNTIME_MODE)
.message("""
Cannot run the plugin with development mode in non-development environment.\
""")
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.UNKNOWN);
return Result.doNotRetry();
}
log.debug("Plugin {} is in development mode", pluginName);
if (StringUtils.isBlank(pluginPathAnno)) {
// should never happen.
throw new IllegalArgumentException(String.format("""
Please set plugin path annotation "%s" \
in development mode for plugin %s.""",
RUNTIME_MODE_ANNO, pluginName));
status.getConditions().addAndEvictFIFO(Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.PLUGIN_PATH_NOT_SET)
.message("""
Plugin path annotation is not set. \
Please set plugin path annotation "%s" in development mode.\
""".formatted(PLUGIN_PATH))
.build());
return Result.doNotRetry();
}
try {
var loadLocation = ResourceUtils.getURL(pluginPathAnno).toURI();
status.setLoadLocation(loadLocation);
} catch (URISyntaxException | FileNotFoundException e) {
throw new IllegalArgumentException(
"Invalid plugin path " + pluginPathAnno + " configured.", e);
// TODO Refactor this using event in the future.
var condition = Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.FALSE)
.reason(ConditionReason.INVALID_PLUGIN_PATH)
.message("Invalid plugin path " + pluginPathAnno + " configured.")
.lastTransitionTime(clock.instant())
.build();
status.getConditions().addAndEvictFIFO(condition);
status.setPhase(Plugin.Phase.UNKNOWN);
return Result.doNotRetry();
}
} else {
// reset annotation PLUGIN_PATH in non-dev mode
@ -535,7 +697,16 @@ public class PluginReconciler implements Reconciler<Request> {
}
status.setLoadLocation(newLoadLocation);
}
status.getConditions().addAndEvictFIFO(Condition.builder()
.type(ConditionType.INITIALIZED)
.status(ConditionStatus.TRUE)
.reason(ConditionReason.LOAD_LOCATION_RESOLVED)
.lastTransitionTime(clock.instant())
.build());
status.setPhase(Plugin.Phase.RESOLVED);
log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName);
return null;
}
@Override
@ -545,7 +716,7 @@ public class PluginReconciler implements Reconciler<Request> {
.build();
}
void createOrUpdateReverseProxy(Plugin plugin) {
private Result createOrUpdateReverseProxy(Plugin plugin) {
String pluginName = plugin.getMetadata().getName();
String reverseProxyName = buildReverseProxyName(pluginName);
ReverseProxy reverseProxy = new ReverseProxy();
@ -570,6 +741,7 @@ public class PluginReconciler implements Reconciler<Request> {
.setVersion(persisted.getMetadata().getVersion());
client.update(reverseProxy);
}, () -> client.create(reverseProxy));
return null;
}
private Path getPluginsRoot() {
@ -587,40 +759,88 @@ public class PluginReconciler implements Reconciler<Request> {
return pluginName + "-system-generated-reverse-proxy";
}
public class PluginStartedListener implements PluginStateListener {
@Override
public void pluginStateChanged(PluginStateEvent event) {
if (PluginState.STARTED.equals(event.getPluginState())) {
var pluginId = event.getPlugin().getPluginId();
client.fetch(Plugin.class, pluginId)
.ifPresent(plugin -> {
if (!Objects.equals(true, plugin.getSpec().getEnabled())) {
log.info("Observed plugin {} started, enabling it.", pluginId);
plugin.getSpec().setEnabled(true);
client.update(plugin);
}
});
}
}
private List<String> requestToUnloadChildren(String pluginName) {
// get all dependencies
var dependents = pluginManager.getDependents(pluginName)
.stream()
.map(PluginWrapper::getPluginId)
.toList();
// request all dependents to reload.
dependents.forEach(dependent -> client.fetch(Plugin.class, dependent)
.ifPresent(childPlugin -> {
var labels = childPlugin.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
childPlugin.getMetadata().setLabels(labels);
}
var label = labels.get(REQUEST_TO_UNLOAD_LABEL);
if (!pluginName.equals(label)) {
labels.put(REQUEST_TO_UNLOAD_LABEL, pluginName);
client.update(childPlugin);
}
}));
return dependents;
}
public class PluginStoppedListener implements PluginStateListener {
private void cancelUnloadRequest(String pluginName) {
// remove label REQUEST_TO_UNLOAD_LABEL
// TODO Use index mechanism
Predicate<Plugin> filter = aplugin -> {
var labels = aplugin.getMetadata().getLabels();
return labels != null && pluginName.equals(labels.get(REQUEST_TO_UNLOAD_LABEL));
};
client.list(Plugin.class, filter, null)
.forEach(aplugin -> {
var labels = aplugin.getMetadata().getLabels();
if (labels != null && labels.remove(REQUEST_TO_UNLOAD_LABEL) != null) {
client.update(aplugin);
}
});
@Override
public void pluginStateChanged(PluginStateEvent event) {
if (PluginState.STOPPED.equals(event.getPluginState())) {
var pluginId = event.getPlugin().getPluginId();
client.fetch(Plugin.class, pluginId)
.ifPresent(plugin -> {
if (!requestToReload(plugin)
&& Objects.equals(true, plugin.getSpec().getEnabled())) {
log.info("Observed plugin {} stopped, disabling it.", pluginId);
plugin.getSpec().setEnabled(false);
client.update(plugin);
}
});
}
}
}
private static void removeConditionBy(ConditionList conditions, String type) {
conditions.removeIf(condition -> Objects.equals(type, condition.getType()));
}
public static class ConditionType {
/**
* Indicates whether the plugin is initialized.
*/
public static final String INITIALIZED = "Initialized";
/**
* Indicates whether the plugin is starting, disabling or deleting.
*/
public static final String PROGRESSING = "Progressing";
/**
* Indicates whether the plugin is ready.
*/
public static final String READY = "Ready";
}
public static class ConditionReason {
public static final String LOAD_LOCATION_RESOLVED = "LoadLocationResolved";
public static final String INVALID_PLUGIN_PATH = "InvalidPluginPath";
public static final String WAIT_FOR_DEPENDENCIES_STARTED = "WaitForDependenciesStarted";
public static final String WAIT_FOR_DEPENDENCIES_LOADED = "WaitForDependenciesLoaded";
public static final String WAIT_FOR_DEPENDENTS_DELETED = "WaitForDependentsDeleted";
public static final String WAIT_FOR_DEPENDENTS_DISABLED = "WaitForDependentsDisabled";
public static final String WAIT_FOR_DEPENDENTS_UNLOADED = "WaitForDependentsUnloaded";
public static final String STARTED = "Started";
public static final String DISABLED = "Disabled";
public static final String SYSTEM_ERROR = "SystemError";
public static final String REQUEST_TO_UNLOAD = "RequestToUnload";
public static final String LOADED = "Loaded";
public static final String START_ERROR = "StartError";
public static final String DISABLE_ERROR = "DisableError";
public static final String INVALID_RUNTIME_MODE = "InvalidRuntimeMode";
public static final String PLUGIN_PATH_NOT_SET = "PluginPathNotSet";
}
}

View File

@ -62,4 +62,15 @@ public interface PluginService {
* @return signed js bundle version by all enabled plugins version.
*/
Mono<String> generateJsBundleVersion();
/**
* Enables or disables a plugin by name.
*
* @param pluginName plugin name
* @param requestToEnable request to enable or disable
* @param wait wait for plugin to be enabled or disabled
* @return updated plugin
*/
Mono<Plugin> changeState(String pluginName, boolean requestToEnable, boolean wait);
}

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.service.impl;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static org.pf4j.PluginState.STARTED;
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
import com.github.zafarkhaja.semver.Version;
@ -10,6 +11,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@ -20,7 +22,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.DependencyResolver;
import org.pf4j.PluginDescriptor;
import org.pf4j.PluginManager;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import org.springframework.core.io.Resource;
@ -36,12 +37,15 @@ import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.service.PluginService;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.exception.PluginAlreadyExistsException;
import run.halo.app.infra.exception.PluginDependenciesNotEnabledException;
import run.halo.app.infra.exception.PluginDependencyException;
import run.halo.app.infra.exception.PluginDependentsNotDisabledException;
import run.halo.app.infra.exception.PluginInstallationException;
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
import run.halo.app.infra.utils.FileUtils;
@ -49,6 +53,7 @@ import run.halo.app.infra.utils.VersionUtils;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.PluginUtils;
import run.halo.app.plugin.SpringPluginManager;
import run.halo.app.plugin.YamlPluginDescriptorFinder;
import run.halo.app.plugin.YamlPluginFinder;
import run.halo.app.plugin.resources.BundleResourceUtils;
@ -67,7 +72,7 @@ public class PluginServiceImpl implements PluginService {
private final PluginProperties pluginProperties;
private final PluginManager pluginManager;
private final SpringPluginManager pluginManager;
@Override
public Flux<Plugin> getPresets() {
@ -252,6 +257,86 @@ public class PluginServiceImpl implements PluginService {
});
}
@Override
public Mono<Plugin> changeState(String pluginName, boolean requestToEnable, boolean wait) {
var updatedPlugin = Mono.defer(() -> client.get(Plugin.class, pluginName))
.flatMap(plugin -> {
if (!Objects.equals(requestToEnable, plugin.getSpec().getEnabled())) {
// preflight check
if (requestToEnable) {
// make sure the dependencies are enabled
var dependencies = plugin.getSpec().getPluginDependencies().keySet();
var notStartedDependencies = dependencies.stream()
.filter(dependency -> {
var pluginWrapper = pluginManager.getPlugin(dependency);
return pluginWrapper == null
|| !Objects.equals(STARTED, pluginWrapper.getPluginState());
})
.toList();
if (!CollectionUtils.isEmpty(notStartedDependencies)) {
return Mono.error(
new PluginDependenciesNotEnabledException(notStartedDependencies)
);
}
} else {
// make sure the dependents are disabled
var dependents = pluginManager.getDependents(pluginName);
var notDisabledDependents = dependents.stream()
.filter(
dependent -> Objects.equals(STARTED, dependent.getPluginState())
)
.map(PluginWrapper::getPluginId)
.toList();
if (!CollectionUtils.isEmpty(notDisabledDependents)) {
return Mono.error(
new PluginDependentsNotDisabledException(notDisabledDependents)
);
}
}
plugin.getSpec().setEnabled(requestToEnable);
log.debug("Updating plugin {} state to {}", pluginName, requestToEnable);
return client.update(plugin);
}
log.debug("Checking plugin {} state, no need to update", pluginName);
return Mono.just(plugin);
});
if (wait) {
// if we want to wait the state of plugin to be updated
updatedPlugin = updatedPlugin
.flatMap(plugin -> {
var phase = plugin.statusNonNull().getPhase();
if (requestToEnable) {
// if we request to enable the plugin
if (!(Plugin.Phase.STARTED.equals(phase)
|| Plugin.Phase.FAILED.equals(phase))) {
return Mono.error(UnexpectedPluginStateException::new);
}
} else {
// if we request to disable the plugin
if (Plugin.Phase.STARTED.equals(phase)) {
return Mono.error(UnexpectedPluginStateException::new);
}
}
return Mono.just(plugin);
})
.retryWhen(
Retry.backoff(10, Duration.ofMillis(100))
.filter(UnexpectedPluginStateException.class::isInstance)
.doBeforeRetry(signal ->
log.debug("Waiting for plugin {} to meet expected state", pluginName)
)
)
.doOnSuccess(plugin -> {
log.info("Plugin {} met expected state {}",
pluginName, plugin.statusNonNull().getPhase());
});
}
return updatedPlugin;
}
Mono<Plugin> findPluginManifest(Path path) {
return Mono.fromSupplier(
() -> {
@ -368,4 +453,9 @@ public class PluginServiceImpl implements PluginService {
oldPlugin.setSpec(newPlugin.getSpec());
oldPlugin.getSpec().setEnabled(enabled);
}
private static class UnexpectedPluginStateException extends RuntimeException {
}
}

View File

@ -0,0 +1,32 @@
package run.halo.app.infra.exception;
import java.net.URI;
import java.util.List;
import org.springframework.web.server.ServerWebInputException;
/**
* Plugin dependencies not enabled exception.
*
* @author johnniang
*/
public class PluginDependenciesNotEnabledException extends ServerWebInputException {
public static final URI TYPE =
URI.create("https://www.halo.run/probs/plugin-dependencies-not-enabled");
/**
* Instantiates a new Plugin dependencies not enabled exception.
*
* @param dependencies dependencies that are not enabled
*/
public PluginDependenciesNotEnabledException(List<String> dependencies) {
super("Plugin dependencies are not fully enabled, please enable them first.",
null,
null,
null,
new Object[] {dependencies});
setType(TYPE);
getBody().setProperty("dependencies", dependencies);
}
}

View File

@ -0,0 +1,32 @@
package run.halo.app.infra.exception;
import java.net.URI;
import java.util.List;
import org.springframework.web.server.ServerWebInputException;
/**
* Plugin dependents not disabled exception.
*
* @author johnniang
*/
public class PluginDependentsNotDisabledException extends ServerWebInputException {
public static final URI TYPE =
URI.create("https://www.halo.run/probs/plugin-dependents-not-disabled");
/**
* Instantiates a new Plugin dependents not disabled exception.
*
* @param dependents dependents that are not disabled
*/
public PluginDependentsNotDisabledException(List<String> dependents) {
super("Plugin dependents are not fully disabled, please disable them first.",
null,
null,
null,
new Object[] {dependents});
setType(TYPE);
getBody().setProperty("dependents", dependents);
}
}

View File

@ -3,9 +3,7 @@ package run.halo.app.plugin;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Stack;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@ -22,7 +20,6 @@ import org.pf4j.PluginDescriptorFinder;
import org.pf4j.PluginFactory;
import org.pf4j.PluginLoader;
import org.pf4j.PluginRepository;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginStatusProvider;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationContext;
@ -162,37 +159,6 @@ public class HaloPluginManager extends DefaultPluginManager implements SpringPlu
return sharedContext.get();
}
public boolean reloadPlugin(String pluginId, Path loadLocation) {
log.info("Reloading plugin {} from {}", pluginId, loadLocation);
var dependents = getDependents(pluginId);
dependents.forEach(plugin -> unloadPlugin(plugin.getPluginId(), false));
var unloaded = unloadPlugin(pluginId, false);
if (!unloaded) {
return false;
}
// load the root plugin
var loadedPluginId = loadPlugin(loadLocation);
if (!Objects.equals(pluginId, loadedPluginId)) {
throw new PluginRuntimeException("""
The plugin {} is reloaded successfully, \
but the plugin id is different from the original one.
""");
}
// load all dependents with reverse order
dependents.stream()
.map(PluginWrapper::getPluginPath)
.sorted(Comparator.reverseOrder())
.forEach(this::loadPlugin);
log.info("Reloaded plugin {} from {}", loadedPluginId, loadLocation);
return true;
}
@Override
public List<PluginWrapper> getDependents(String pluginId) {
var dependents = new ArrayList<PluginWrapper>();

View File

@ -16,6 +16,8 @@ public interface PluginConst {
String RELOAD_ANNO = "plugin.halo.run/reload";
String REQUEST_TO_UNLOAD_LABEL = "plugin.halo.run/request-to-unload";
String PLUGIN_PATH = "plugin.halo.run/plugin-path";
String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode";

View File

@ -1,6 +1,5 @@
package run.halo.app.plugin;
import java.nio.file.Path;
import java.util.List;
import org.pf4j.PluginManager;
import org.pf4j.PluginWrapper;
@ -12,15 +11,6 @@ public interface SpringPluginManager extends PluginManager {
ApplicationContext getSharedContext();
/**
* Reload the plugin and the plugins that depend on it.
*
* @param pluginId plugin id
* @param loadLocation new load location
* @return true if reload successfully, otherwise false
*/
boolean reloadPlugin(String pluginId, Path loadLocation);
/**
* Get all dependents recursively.
*

View File

@ -24,6 +24,8 @@ problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=Email V
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=Cyclic Dependency Detected
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies Not Found
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Wrong Dependency Version
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled
problemDetail.title.internalServerError=Internal Server Error
# Detail definitions
@ -46,6 +48,8 @@ problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=Invalid email
problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=A cyclic dependency was detected.
problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies "{0}" were not found.
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}.
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first.
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first.
problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry.
problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later.

View File

@ -12,6 +12,8 @@ problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=邮箱
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=循环依赖
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖未找到
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本错误
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用
problemDetail.title.internalServerError=服务器内部错误
problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。
@ -23,6 +25,8 @@ problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=验证码错
problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=检测到循环依赖。
problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖“{0}”未找到。
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。
problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。
problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。

View File

@ -2,8 +2,6 @@ package run.halo.app.core.extension.endpoint;
import static java.util.Objects.requireNonNull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.argThat;
@ -11,7 +9,6 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
@ -478,69 +475,4 @@ class PluginEndpointTest {
}
}
@Nested
class PluginStateChangeTest {
WebTestClient webClient;
@BeforeEach
void setUp() {
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
.build();
}
@Test
void shouldEnablePluginIfPluginWasNotStarted() {
var plugin = createPlugin("fake-plugin");
plugin.getSpec().setEnabled(false);
plugin.statusNonNull().setPhase(Plugin.Phase.RESOLVED);
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
.thenReturn(Mono.fromSupplier(() -> {
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
return plugin;
}));
when(client.update(plugin)).thenReturn(Mono.just(plugin));
var requestBody = new PluginEndpoint.RunningStateRequest();
requestBody.setEnable(true);
requestBody.setAsync(false);
webClient.put().uri("/plugins/fake-plugin/plugin-state")
.bodyValue(requestBody)
.exchange()
.expectStatus().isOk()
.expectBody(Plugin.class)
.value(p -> assertTrue(p.getSpec().getEnabled()));
verify(client, times(2)).get(Plugin.class, "fake-plugin");
verify(client).update(plugin);
}
@Test
void shouldDisablePluginIfAlreadyStarted() {
var plugin = createPlugin("fake-plugin");
plugin.getSpec().setEnabled(true);
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
.thenReturn(Mono.fromSupplier(() -> {
plugin.getStatus().setPhase(Plugin.Phase.STOPPED);
return plugin;
}));
when(client.update(plugin)).thenReturn(Mono.just(plugin));
var requestBody = new PluginEndpoint.RunningStateRequest();
requestBody.setEnable(false);
requestBody.setAsync(false);
webClient.put().uri("/plugins/fake-plugin/plugin-state")
.bodyValue(requestBody)
.exchange()
.expectStatus().isOk()
.expectBody(Plugin.class)
.value(p -> assertFalse(p.getSpec().getEnabled()));
verify(client, times(2)).get(Plugin.class, "fake-plugin");
verify(client).update(plugin);
}
}
}

View File

@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@ -40,6 +40,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.pf4j.DefaultPluginDescriptor;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
@ -53,6 +54,7 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.SpringPluginManager;
@ -122,11 +124,22 @@ class PluginReconcilerTest {
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
var t = assertThrows(IllegalStateException.class,
() -> reconciler.reconcile(new Request(name)));
assertEquals(
"Cannot run the plugin fake-plugin with dev mode in non-development environment.",
t.getMessage());
var result = reconciler.reconcile(new Request(name));
assertFalse(result.reEnqueue());
var status = fakePlugin.getStatus();
assertEquals(Plugin.Phase.UNKNOWN, status.getPhase());
var condition = status.getConditions().peekFirst();
assertEquals(Condition.builder()
.type(PluginReconciler.ConditionType.INITIALIZED)
.status(ConditionStatus.FALSE)
.reason(PluginReconciler.ConditionReason.INVALID_RUNTIME_MODE)
.message("""
Cannot run the plugin with development mode in non-development environment.\
""")
.build(), condition);
verify(client).update(fakePlugin);
verify(client).fetch(Plugin.class, name);
verify(pluginProperties).getRuntimeMode();
verify(pluginManager, never()).loadPlugin(any(Path.class));
@ -177,12 +190,8 @@ class PluginReconcilerTest {
.thenReturn(null);
when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT);
var gotException = assertThrows(IllegalArgumentException.class,
() -> reconciler.reconcile(new Request(name)));
assertEquals("""
Please set plugin path annotation "plugin.halo.run/runtime-mode" in development \
mode for plugin fake-plugin.""", gotException.getMessage());
var result = reconciler.reconcile(new Request(name));
assertFalse(result.reEnqueue());
}
@Test
@ -197,15 +206,18 @@ class PluginReconcilerTest {
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath));
when(pluginManager.getPlugin(name)).thenReturn(mock(PluginWrapper.class));
when(pluginManager.reloadPlugin(eq(name), any(Path.class))).thenReturn(true);
var pluginWrapper = mockPluginWrapper(PluginState.RESOLVED);
when(pluginManager.getPlugin(name)).thenReturn(pluginWrapper);
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
when(pluginManager.getUnresolvedPlugins()).thenReturn(List.of(pluginWrapper));
when(pluginManager.getResolvedPlugins()).thenReturn(List.of());
var result = reconciler.reconcile(new Request(name));
assertFalse(result.reEnqueue());
verify(pluginManager).unloadPlugin(name);
var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation());
verify(pluginManager).reloadPlugin(name, loadLocation);
verify(pluginManager).loadPlugin(loadLocation);
}
@Test
@ -229,9 +241,19 @@ class PluginReconcilerTest {
.thenReturn(mockPluginWrapperForStaticResources());
when(pluginManager.startPlugin(name)).thenReturn(PluginState.FAILED);
var e = assertThrows(IllegalStateException.class,
() -> reconciler.reconcile(new Request(name)));
assertEquals("Failed to start plugin fake-plugin", e.getMessage());
var result = reconciler.reconcile(new Request(name));
assertFalse(result.reEnqueue());
verify(client).update(fakePlugin);
var status = fakePlugin.getStatus();
assertEquals(Plugin.Phase.FAILED, status.getPhase());
var condition = status.getConditions().peekFirst();
assertEquals(Condition.builder()
.type(PluginReconciler.ConditionType.READY)
.status(ConditionStatus.FALSE)
.reason(PluginReconciler.ConditionReason.START_ERROR)
.message("Failed to start plugin fake-plugin(FAILED).")
.build(), condition);
}
@Test
@ -253,6 +275,8 @@ class PluginReconcilerTest {
// get setting extension
.thenReturn(mockPluginWrapperForSetting())
.thenReturn(mockPluginWrapperForStaticResources())
// before starting
.thenReturn(mockPluginWrapper(PluginState.STOPPED))
// sync plugin state
.thenReturn(mockPluginWrapper(PluginState.STARTED));
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
@ -277,13 +301,13 @@ class PluginReconcilerTest {
assertNotNull(fakePlugin.getStatus().getLastStartTime());
var condition = fakePlugin.getStatus().getConditions().peek();
assertEquals("STARTED", condition.getType());
assertEquals(PluginReconciler.ConditionType.READY, condition.getType());
assertEquals(ConditionStatus.TRUE, condition.getStatus());
assertEquals(clock.instant(), condition.getLastTransitionTime());
verify(pluginManager).startPlugin(name);
verify(pluginManager).loadPlugin(loadLocation);
verify(pluginManager, times(4)).getPlugin(name);
verify(pluginManager, times(5)).getPlugin(name);
verify(client).update(fakePlugin);
verify(client).fetch(Setting.class, settingName);
verify(client).create(any(Setting.class));
@ -354,8 +378,9 @@ class PluginReconcilerTest {
var pluginRootResource =
new DefaultResourceLoader().getResource("classpath:plugin/plugin-0.0.1/");
var classLoader = new URLClassLoader(new URL[]{pluginRootResource.getURL()}, null);
var classLoader = new URLClassLoader(new URL[] {pluginRootResource.getURL()}, null);
when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader);
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
return pluginWrapper;
}
@ -368,12 +393,14 @@ class PluginReconcilerTest {
when(pluginClassLoader.getResource("console/style.css")).thenReturn(
mock(URL.class));
when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader);
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
return pluginWrapper;
}
PluginWrapper mockPluginWrapper(PluginState state) {
var pluginWrapper = mock(PluginWrapper.class);
when(pluginWrapper.getPluginState()).thenReturn(state);
lenient().when(pluginWrapper.getPluginState()).thenReturn(state);
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
return pluginWrapper;
}

View File

@ -3,6 +3,7 @@ package run.halo.app.core.extension.service.impl;
import static java.util.Objects.requireNonNull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.isA;
@ -40,9 +41,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.exception.PluginAlreadyExistsException;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.SpringPluginManager;
import run.halo.app.plugin.YamlPluginFinder;
@ExtendWith(MockitoExtension.class)
@ -58,7 +59,7 @@ class PluginServiceImplTest {
PluginProperties pluginProperties;
@Mock
HaloPluginManager pluginManager;
SpringPluginManager pluginManager;
@InjectMocks
PluginServiceImpl pluginService;
@ -233,15 +234,6 @@ class PluginServiceImplTest {
verify(client).update(testPlugin);
}
Plugin createPlugin(String name, Consumer<Plugin> pluginConsumer) {
var plugin = new Plugin();
plugin.setMetadata(new Metadata());
plugin.getMetadata().setName(name);
plugin.setSpec(new Plugin.PluginSpec());
plugin.setStatus(new Plugin.PluginStatus());
pluginConsumer.accept(plugin);
return plugin;
}
}
@ -299,4 +291,61 @@ class PluginServiceImplTest {
assertThat(result).isNotEqualTo(result2);
}
@Nested
class PluginStateChangeTest {
@Test
void shouldEnablePluginIfPluginWasNotStarted() {
var plugin = createPlugin("fake-plugin", p -> {
p.getSpec().setEnabled(false);
p.statusNonNull().setPhase(Plugin.Phase.RESOLVED);
});
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
.thenReturn(Mono.fromSupplier(() -> {
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
return plugin;
}));
when(client.update(plugin)).thenReturn(Mono.just(plugin));
pluginService.changeState("fake-plugin", true, false)
.as(StepVerifier::create)
.expectNext(plugin)
.verifyComplete();
assertTrue(plugin.getSpec().getEnabled());
}
@Test
void shouldDisablePluginIfAlreadyStarted() {
var plugin = createPlugin("fake-plugin", p -> {
p.getSpec().setEnabled(true);
p.statusNonNull().setPhase(Plugin.Phase.STARTED);
});
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
.thenReturn(Mono.fromSupplier(() -> {
plugin.getStatus().setPhase(Plugin.Phase.STOPPED);
return plugin;
}));
when(client.update(plugin)).thenReturn(Mono.just(plugin));
pluginService.changeState("fake-plugin", false, false)
.as(StepVerifier::create)
.expectNext(plugin)
.verifyComplete();
assertFalse(plugin.getSpec().getEnabled());
}
}
Plugin createPlugin(String name, Consumer<Plugin> pluginConsumer) {
var plugin = new Plugin();
plugin.setMetadata(new Metadata());
plugin.getMetadata().setName(name);
plugin.setSpec(new Plugin.PluginSpec());
plugin.setStatus(new Plugin.PluginStatus());
pluginConsumer.accept(plugin);
return plugin;
}
}

View File

@ -1,7 +1,10 @@
package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
@ -142,6 +145,86 @@ class ConditionListTest {
assertThat(conditions.peek().getType()).isEqualTo("type3");
}
@Test
void shouldNotAddIfTypeIsSame() {
var conditions = new ConditionList();
var condition = Condition.builder()
.type("type")
.status(ConditionStatus.TRUE)
.reason("reason")
.message("message")
.build();
var anotherCondition = Condition.builder()
.type("type")
.status(ConditionStatus.FALSE)
.reason("another reason")
.message("another message")
.build();
conditions.addAndEvictFIFO(condition);
conditions.addAndEvictFIFO(anotherCondition);
assertEquals(1, conditions.size());
}
@Test
void shouldNotUpdateLastTransitionTimeIfStatusNotChanged() {
var now = Instant.now();
var conditions = new ConditionList();
conditions.addAndEvictFIFO(
Condition.builder()
.type("type")
.status(ConditionStatus.TRUE)
.reason("reason")
.message("message")
.lastTransitionTime(now)
.build()
);
conditions.addAndEvictFIFO(
Condition.builder()
.type("type")
.status(ConditionStatus.TRUE)
.reason("reason")
.message("message")
.lastTransitionTime(now.plus(Duration.ofSeconds(1)))
.build()
);
assertEquals(1, conditions.size());
// make sure the last transition time was not modified.
assertEquals(now, conditions.peek().getLastTransitionTime());
}
@Test
void shouldUpdateLastTransitionTimeIfStatusChanged() {
var now = Instant.now();
var conditions = new ConditionList();
conditions.addAndEvictFIFO(
Condition.builder()
.type("type")
.status(ConditionStatus.TRUE)
.reason("reason")
.message("message")
.lastTransitionTime(now)
.build()
);
conditions.addAndEvictFIFO(
Condition.builder()
.type("type")
.status(ConditionStatus.FALSE)
.reason("reason")
.message("message")
.lastTransitionTime(now.plus(Duration.ofSeconds(1)))
.build()
);
assertEquals(1, conditions.size());
assertEquals(now.plus(Duration.ofSeconds(1)), conditions.peek().getLastTransitionTime());
}
private Condition condition(String type, String message, String reason,
ConditionStatus status) {
Condition condition = new Condition();

View File

@ -0,0 +1,93 @@
<script lang="ts" setup>
import { VButton, VModal } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import { ref } from "vue";
withDefaults(defineProps<{ plugin: Plugin }>(), {});
const emit = defineEmits<{
(event: "close"): void;
}>();
const modal = ref();
</script>
<template>
<VModal
ref="modal"
:body-class="['!p-0']"
:title="$t('core.plugin.conditions_modal.title')"
:width="900"
layer-closable
@close="emit('close')"
>
<table class="min-w-full divide-y divide-gray-100">
<thead class="bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-sm font-semibold text-gray-900 sm:w-96"
scope="col"
>
{{ $t("core.plugin.conditions_modal.fields.type") }}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
>
{{ $t("core.plugin.conditions_modal.fields.status") }}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
>
{{ $t("core.plugin.conditions_modal.fields.reason") }}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
>
{{ $t("core.plugin.conditions_modal.fields.message") }}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
>
{{ $t("core.plugin.conditions_modal.fields.last_transition_time") }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="(condition, index) in plugin?.status?.conditions"
:key="index"
>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
{{ condition.type }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{{ condition.status }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{{ condition.reason || "-" }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{{ condition.message || "-" }}
</td>
<td
v-tooltip="formatDatetime(condition.lastTransitionTime)"
class="whitespace-nowrap px-4 py-3 text-sm text-gray-500"
>
{{ relativeTimeTo(condition.lastTransitionTime) }}
</td>
</tr>
</tbody>
</table>
<template #footer>
<VButton @click="modal.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</template>
</VModal>
</template>

View File

@ -43,7 +43,8 @@ const { plugin } = toRefs(props);
const selectedNames = inject<Ref<string[]>>("selectedNames", ref([]));
const { getStatusMessage, uninstall } = usePluginLifeCycle(plugin);
const { getStatusDotState, getStatusMessage, uninstall } =
usePluginLifeCycle(plugin);
const pluginUpgradeModalVisible = ref(false);
@ -147,19 +148,11 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<Plugin>(
"plugin:list-item:field:create",
plugin,
computed((): EntityFieldItem[] => {
const { enabled } = props.plugin.spec || {};
const { phase } = props.plugin.status || {};
const shouldHideStatusDot =
!enabled || (enabled && phase === PluginStatusPhaseEnum.Started);
const getStatusDotState = () => {
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
return "error";
}
return "default";
};
phase === PluginStatusPhaseEnum.Started ||
phase === PluginStatusPhaseEnum.Disabled;
return [
{

View File

@ -9,6 +9,7 @@ import { useMutation } from "@tanstack/vue-query";
interface usePluginLifeCycleReturn {
isStarted: ComputedRef<boolean | undefined>;
getStatusDotState: () => string;
getStatusMessage: () => string | undefined;
changeStatus: () => void;
changingStatus: Ref<boolean>;
@ -27,26 +28,41 @@ export function usePluginLifeCycle(
);
});
const getStatusDotState = () => {
const { phase } = plugin?.value?.status || {};
const { enabled } = plugin?.value?.spec || {};
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
return "error";
}
if (phase === PluginStatusPhaseEnum.Disabling) {
return "warning";
}
return "default";
};
const getStatusMessage = () => {
if (!plugin?.value) return;
const { enabled } = plugin.value.spec || {};
const { phase } = plugin.value.status || {};
// Starting failed
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
if (
phase === PluginStatusPhaseEnum.Failed ||
phase === PluginStatusPhaseEnum.Disabling
) {
const lastCondition = plugin.value.status?.conditions?.[0];
return (
[lastCondition?.reason, lastCondition?.message]
.filter(Boolean)
.join(":") || "Unknown"
.join(": ") || "Unknown"
);
}
// Starting up
if (
enabled &&
phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed)
) {
return t("core.common.status.starting_up");
@ -153,6 +169,7 @@ export function usePluginLifeCycle(
return {
isStarted,
getStatusDotState,
getStatusMessage,
changeStatus,
changingStatus,

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import {
VAlert,
VButton,
VDescription,
VDescriptionItem,
VSwitch,
@ -8,12 +9,18 @@ import {
import type { Ref } from "vue";
import { computed, inject } from "vue";
import { apiClient } from "@/utils/api-client";
import type { Plugin, Role } from "@halo-dev/api-client";
import {
PluginStatusPhaseEnum,
type Plugin,
type Role,
} from "@halo-dev/api-client";
import { pluginLabels, roleLabels } from "@/constants/labels";
import { rbacAnnotations } from "@/constants/annotations";
import { usePluginLifeCycle } from "../composables/use-plugin";
import { formatDatetime } from "@/utils/date";
import { useQuery } from "@tanstack/vue-query";
import { ref } from "vue";
import PluginConditionsModal from "../components/PluginConditionsModal.vue";
const plugin = inject<Ref<Plugin | undefined>>("plugin");
const { changeStatus, changingStatus } = usePluginLifeCycle(plugin);
@ -60,9 +67,30 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
});
return groups;
});
// Error alert
const conditionsModalVisible = ref(false);
const errorAlertVisible = computed(() => {
const { phase } = plugin?.value?.status || {};
return (
phase !== PluginStatusPhaseEnum.Started &&
phase !== PluginStatusPhaseEnum.Disabled
);
});
const lastCondition = computed(() => {
return plugin?.value?.status?.conditions?.[0];
});
</script>
<template>
<PluginConditionsModal
v-if="conditionsModalVisible && plugin"
:plugin="plugin"
@close="conditionsModalVisible = false"
/>
<Transition mode="out-in" name="fade">
<div class="overflow-hidden rounded-b-base">
<div class="flex items-center justify-between bg-white px-4 py-4 sm:px-6">
@ -80,18 +108,21 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
</div>
</div>
<div
v-if="
plugin?.status?.phase === 'FAILED' &&
plugin?.status?.conditions?.length
"
v-if="errorAlertVisible && lastCondition"
class="w-full px-4 pb-2 sm:px-6"
>
<VAlert
type="error"
:title="plugin?.status?.conditions?.[0].reason"
:description="plugin?.status?.conditions?.[0].message"
:title="lastCondition.reason"
:description="lastCondition.message"
:closable="false"
/>
>
<template #actions>
<VButton size="sm" @click="conditionsModalVisible = true">
{{ $t("core.plugin.detail.operations.view_conditions.button") }}
</VButton>
</template>
</VAlert>
</div>
<div class="border-t border-gray-200">
<VDescription>

View File

@ -31,13 +31,13 @@ export interface Condition {
* @type {string}
* @memberof Condition
*/
'message': string;
'message'?: string;
/**
*
* @type {string}
* @memberof Condition
*/
'reason': string;
'reason'?: string;
/**
*
* @type {string}

View File

@ -88,6 +88,7 @@ export const PluginStatusPhaseEnum = {
Pending: 'PENDING',
Starting: 'STARTING',
Created: 'CREATED',
Disabling: 'DISABLING',
Disabled: 'DISABLED',
Resolved: 'RESOLVED',
Started: 'STARTED',

View File

@ -908,6 +908,9 @@ core:
repo: Source Repository
load_location: Storage Location
issues: Issues feedback
operations:
view_conditions:
button: View recent conditions
loader:
toast:
entry_load_failed: Failed to load plugins entry file
@ -916,6 +919,14 @@ core:
editor:
providers:
default: Default Editor
conditions_modal:
title: Recent conditions
fields:
type: Type
status: Status
reason: Reason
message: Message
last_transition_time: Last transition time
user:
title: Users
actions:

View File

@ -864,6 +864,9 @@ core:
repo: 源码仓库
load_location: 存储位置
issues: 问题反馈
operations:
view_conditions:
button: 查看最近状态
loader:
toast:
entry_load_failed: 加载插件入口文件失败
@ -872,6 +875,14 @@ core:
editor:
providers:
default: 默认编辑器
conditions_modal:
title: 插件最近状态
fields:
type: 类型
status: 状态
reason: 原因
message: 信息
last_transition_time: 时间
user:
title: 用户
actions:

View File

@ -844,6 +844,9 @@ core:
repo: 源碼倉庫
load_location: 存儲位置
issues: 問題回饋
operations:
view_conditions:
button: 查看最近狀態
loader:
toast:
entry_load_failed: 讀取插件入口文件失敗
@ -852,6 +855,14 @@ core:
editor:
providers:
default: 預設編輯器
conditions_modal:
title: 插件最近狀態
fields:
type: 類型
status: 狀態
reason: 原因
message: 訊息
last_transition_time: 時間
user:
title: 用戶
actions: