Compare commits

...

1 Commits

Author SHA1 Message Date
Malaydewangan09
769adad5e8 feat(): add support for preview renderers 2025-08-25 23:21:13 +05:30
16 changed files with 335 additions and 16 deletions

View File

@@ -6,6 +6,8 @@ import io.kestra.core.plugins.PluginCatalogService;
import io.kestra.core.plugins.PluginRegistry;
import io.kestra.core.storages.StorageInterface;
import io.kestra.core.storages.StorageInterfaceFactory;
import io.kestra.plugin.core.preview.PreviewRendererFactory;
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
import io.micronaut.context.annotation.Bean;
import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.context.annotation.Factory;
@@ -87,4 +89,9 @@ public class KestraBeansFactory {
return (Map<String, Object>) storage.get(StringConvention.CAMEL_CASE.format(type));
}
}
@Singleton
public PreviewRendererFactory previewRendererFactory(final PluginRegistry pluginRegistry) {
return new PreviewRendererFactory(pluginRegistry);
}
}

View File

@@ -4,6 +4,8 @@ import io.kestra.core.models.ServerType;
import io.kestra.core.plugins.PluginRegistry;
import io.kestra.core.storages.StorageInterface;
import io.kestra.core.utils.VersionProvider;
import io.kestra.plugin.core.preview.PreviewRenderer;
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
import io.micronaut.context.ApplicationContext;
import io.micronaut.context.annotation.Context;
import io.micronaut.context.annotation.Requires;
@@ -82,6 +84,8 @@ public abstract class KestraContext {
*/
public abstract PluginRegistry getPluginRegistry();
public abstract PreviewRenderer getPreviewRenderer();
public abstract StorageInterface getStorageInterface();
/**
@@ -107,8 +111,8 @@ public abstract class KestraContext {
/**
* Creates a new {@link KestraContext} instance.
*
* @param applicationContext The {@link ApplicationContext}.
* @param environment The {@link Environment}.
* @param applicationContext The {@link ApplicationContext}.
* @param environment The {@link Environment}.
*/
public Initializer(ApplicationContext applicationContext,
Environment environment) {
@@ -118,7 +122,9 @@ public abstract class KestraContext {
KestraContext.setContext(this);
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public ServerType getServerType() {
return Optional.ofNullable(environment)
@@ -126,20 +132,27 @@ public abstract class KestraContext {
.orElse(ServerType.STANDALONE);
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public Optional<Integer> getWorkerMaxNumThreads() {
return Optional.ofNullable(environment)
.flatMap(env -> env.getProperty(KESTRA_WORKER_MAX_NUM_THREADS, Integer.class));
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public Optional<String> getWorkerGroupKey() {
return Optional.ofNullable(environment)
.flatMap(env -> env.getProperty(KESTRA_WORKER_GROUP_KEY, String.class));
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public void injectWorkerConfigs(Integer maxNumThreads, String workerGroupKey) {
final Map<String, Object> configs = new HashMap<>();
@@ -154,7 +167,9 @@ public abstract class KestraContext {
}
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public void shutdown() {
if (isShutdown.compareAndSet(false, true)) {
@@ -164,13 +179,17 @@ public abstract class KestraContext {
}
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public String getVersion() {
return version;
}
/** {@inheritDoc} **/
/**
* {@inheritDoc}
**/
@Override
public PluginRegistry getPluginRegistry() {
// Lazy init of the PluginRegistry.
@@ -182,5 +201,11 @@ public abstract class KestraContext {
// Lazy init of the PluginRegistry.
return this.applicationContext.getBean(StorageInterface.class);
}
@Override
public PreviewRenderer getPreviewRenderer() {
// Lazy init of the PreviewRenderer.
return this.applicationContext.getBean(PreviewRenderer.class);
}
}
}

View File

@@ -13,6 +13,7 @@ import io.kestra.core.models.tasks.runners.TaskRunner;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.secret.SecretPluginInterface;
import io.kestra.core.storages.StorageInterface;
import io.kestra.plugin.core.preview.PreviewRenderer;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
@@ -117,6 +118,7 @@ public class PluginScanner {
List<Class<? extends AdditionalPlugin>> additionalPlugins = new ArrayList<>();
List<String> guides = new ArrayList<>();
Map<String, Class<?>> aliases = new HashMap<>();
List<Class<? extends PreviewRenderer>> previewRenderers = new ArrayList<>();
if (manifest == null) {
manifest = getManifest(classLoader);
@@ -186,6 +188,11 @@ public class PluginScanner {
log.debug("Loading additional plugin: '{}'", plugin.getClass());
additionalPlugins.add(additionalPlugin.getClass());
}
case PreviewRenderer previewRenderer -> {
log.info("Found PreviewRenderer: {}", plugin.getClass().getName());
log.debug("Loading PreviewRenderer plugin: '{}'", plugin.getClass());
previewRenderers.add(previewRenderer.getClass());
}
default -> {
}
}
@@ -236,6 +243,7 @@ public class PluginScanner {
e -> e.getKey().toLowerCase(),
Function.identity()
)))
.previewRenderers(previewRenderers)
.build();
}

View File

@@ -13,6 +13,7 @@ import io.kestra.core.models.tasks.runners.TaskRunner;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.secret.SecretPluginInterface;
import io.kestra.core.storages.StorageInterface;
import io.kestra.plugin.core.preview.PreviewRenderer;
import lombok.*;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
@@ -46,6 +47,8 @@ public class RegisteredPlugin {
public static final String DATA_FILTERS_KPI_GROUP_NAME = "data-filters-kpi";
public static final String LOG_EXPORTERS_GROUP_NAME = "log-exporters";
public static final String ADDITIONAL_PLUGINS_GROUP_NAME = "additional-plugins";
public static final String PREVIEW_RENDERERS_GROUP_NAME = "preview-renderers";
private final ExternalPlugin externalPlugin;
private final Manifest manifest;
@@ -63,6 +66,7 @@ public class RegisteredPlugin {
private final List<Class<? extends DataFilterKPI<?, ?>>> dataFiltersKPI;
private final List<Class<? extends LogExporter<?>>> logExporters;
private final List<Class<? extends AdditionalPlugin>> additionalPlugins;
private final List<Class<? extends PreviewRenderer>> previewRenderers;
private final List<String> guides;
// Map<lowercasealias, <Alias, Class>>
private final Map<String, Map.Entry<String, Class<?>>> aliases;
@@ -117,6 +121,10 @@ public class RegisteredPlugin {
return StorageInterface.class;
}
if (this.getPreviewRenderers().stream().anyMatch(r -> r.getName().equals(cls))) {
return PreviewRenderer.class;
}
if (this.getSecrets().stream().anyMatch(r -> r.getName().equals(cls))) {
return SecretPluginInterface.class;
}
@@ -187,6 +195,7 @@ public class RegisteredPlugin {
result.put(DATA_FILTERS_KPI_GROUP_NAME, Arrays.asList(this.getDataFiltersKPI().toArray(Class[]::new)));
result.put(LOG_EXPORTERS_GROUP_NAME, Arrays.asList(this.getLogExporters().toArray(Class[]::new)));
result.put(ADDITIONAL_PLUGINS_GROUP_NAME, Arrays.asList(this.getAdditionalPlugins().toArray(Class[]::new)));
result.put(PREVIEW_RENDERERS_GROUP_NAME, Arrays.asList(this.getPreviewRenderers().toArray(Class[]::new)));
return result;
}

View File

@@ -16,6 +16,8 @@ import io.kestra.core.storages.StorageInterface;
import io.kestra.core.storages.kv.KVStore;
import io.kestra.core.utils.ListUtils;
import io.kestra.core.utils.VersionProvider;
import io.kestra.plugin.core.preview.PreviewRenderer;
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
import io.micronaut.context.ApplicationContext;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.ConstraintViolation;
@@ -602,6 +604,8 @@ public class DefaultRunContext extends RunContext {
private List<String> secretInputs;
private Task task;
private AbstractTrigger trigger;
private PreviewRenderer previewRenderer;
private PreviewRendererRegistry previewRendererRegistry;
/**
* Builds the new {@link DefaultRunContext} object.

View File

@@ -1,4 +1,4 @@
package io.kestra.webserver.utils.filepreview;
package io.kestra.plugin.core.preview;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
@@ -18,7 +18,7 @@ public abstract class FileRender {
@JsonInclude
public boolean truncated = false;
FileRender(String extension, Integer maxLine) {
protected FileRender(String extension, Integer maxLine) {
this.maxLine = maxLine;
this.extension = extension;
}

View File

@@ -0,0 +1,33 @@
package io.kestra.plugin.core.preview;
import io.kestra.core.models.Plugin;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Optional;
/**
* Interface for plugins to provide file preview rendering capabilities.
* Plugins can implement this to support preview of specific file formats.
*/
public interface PreviewRenderer extends Plugin {
/**
* File extensions this renderer supports (without dot, e.g., "parquet", "csv")
*/
List<String> supportedExtensions();
/**
* Render preview for the given file
*
* @param extension file extension
* @param fileStream input stream of the file
* @param charset charset for text-based files (optional)
* @param maxLines maximum number of lines/records to preview
* @return PreviewResult object containing preview data
* @throws IOException if file cannot be read or parsed
*/
PreviewResult render(String extension, InputStream fileStream, Optional<Charset> charset, Integer maxLines) throws IOException;
}

View File

@@ -0,0 +1,109 @@
package io.kestra.plugin.core.preview;
import io.kestra.core.plugins.PluginRegistry;
import io.kestra.core.plugins.RegisteredPlugin;
import io.kestra.plugin.core.preview.PreviewRenderer;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Slf4j
public class PreviewRendererFactory {
private final PluginRegistry pluginRegistry;
private Map<String, Class<? extends PreviewRenderer>> rendererClasses;
public PreviewRendererFactory(PluginRegistry pluginRegistry) {
this.pluginRegistry = pluginRegistry;
}
/**
* Get preview renderer for given file extension
*/
public Optional<PreviewRenderer> getRenderer(String extension) {
log.info("Looking for preview renderer for extension: '{}'", extension);
if (rendererClasses == null) {
log.info("Renderer classes not initialized, initializing now...");
initializeRenderers();
}
String normalizedExt = extension.toLowerCase();
Class<? extends PreviewRenderer> rendererClass = rendererClasses.get(normalizedExt);
log.info("Available extensions: {}", rendererClasses.keySet());
log.info("Looking for normalized extension: '{}', found class: {}", normalizedExt,
rendererClass != null ? rendererClass.getName() : "null");
if (rendererClass == null) {
log.warn("No preview renderer found for extension '{}'", extension);
return Optional.empty();
}
try {
PreviewRenderer renderer = rendererClass.getDeclaredConstructor().newInstance();
log.info("Successfully created preview renderer instance: {}", rendererClass.getSimpleName());
return Optional.of(renderer);
} catch (Exception e) {
log.error("Failed to instantiate preview renderer for extension '{}': {}", extension, e.getMessage(), e);
return Optional.empty();
}
}
/**
* Initialize renderers by discovering all PreviewRenderer plugins
*/
private void initializeRenderers() {
rendererClasses = new HashMap<>();
log.info("Starting to initialize preview renderers...");
List<RegisteredPlugin> plugins = pluginRegistry.plugins().stream().toList();
log.info("Found {} registered plugins", plugins.size());
plugins.forEach(plugin -> {
List<Class<? extends PreviewRenderer>> renderers = plugin.getPreviewRenderers();
log.info("Plugin '{}' has {} preview renderers", plugin.name(), renderers.size());
renderers.forEach(rendererClass ->
log.info(" - Preview renderer class: {}", rendererClass.getName())
);
});
pluginRegistry.plugins()
.stream()
.map(RegisteredPlugin::getPreviewRenderers)
.flatMap(List::stream)
.forEach(rendererClass -> {
try {
log.info("Trying to instantiate preview renderer: {}", rendererClass.getName());
PreviewRenderer instance = rendererClass.getDeclaredConstructor().newInstance();
List<String> extensions = instance.supportedExtensions();
log.info("Preview renderer {} supports extensions: {}", rendererClass.getSimpleName(), extensions);
for (String extension : extensions) {
String normalizedExt = extension.toLowerCase();
rendererClasses.put(normalizedExt, rendererClass);
log.info("Registered preview renderer for '{}': {}", normalizedExt, rendererClass.getSimpleName());
}
} catch (Exception e) {
log.error("Failed to register preview renderer {}: {}", rendererClass.getSimpleName(), e.getMessage(), e);
}
});
log.info("Initialization complete. Registered renderers for extensions: {}", rendererClasses.keySet());
}
/**
* Get all supported extensions
*/
public List<String> getSupportedExtensions() {
if (rendererClasses == null) {
initializeRenderers();
}
return List.copyOf(rendererClasses.keySet());
}
}

View File

@@ -0,0 +1,44 @@
package io.kestra.plugin.core.preview;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Singleton
@Slf4j
public class PreviewRendererRegistry {
private final Map<String, PreviewRenderer> renderers = new HashMap<>();
/**
* Register a preview renderer for specific file extensions
*/
public void register(PreviewRenderer renderer) {
for (String extension : renderer.supportedExtensions()) {
String normalizedExt = extension.toLowerCase();
if (renderers.containsKey(normalizedExt)) {
log.warn("Preview renderer for extension '{}' is being overridden by {}",
normalizedExt, renderer.getClass().getSimpleName());
}
renderers.put(normalizedExt, renderer);
log.debug("Registered preview renderer for '{}': {}", normalizedExt, renderer.getClass().getSimpleName());
}
}
/**
* Get preview renderer for given file extension
*/
public Optional<PreviewRenderer> getRenderer(String extension) {
return Optional.ofNullable(renderers.get(extension.toLowerCase()));
}
/**
* Check if preview is available for given extension
*/
public boolean hasRenderer(String extension) {
return renderers.containsKey(extension.toLowerCase());
}
}

View File

@@ -0,0 +1,25 @@
package io.kestra.plugin.core.preview;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PreviewResult {
public String extension;
public Type type;
public Object content;
public Integer maxLines;
@JsonInclude
public boolean truncated = false;
public enum Type {
TEXT, LIST, IMAGE, MARKDOWN, PDF
}
}

View File

@@ -47,7 +47,7 @@ import io.kestra.webserver.services.ExecutionDependenciesStreamingService;
import io.kestra.webserver.services.ExecutionStreamingService;
import io.kestra.webserver.utils.PageableUtils;
import io.kestra.webserver.utils.RequestUtils;
import io.kestra.webserver.utils.filepreview.FileRender;
import io.kestra.plugin.core.preview.FileRender;
import io.kestra.webserver.utils.filepreview.FileRenderBuilder;
import io.micronaut.context.ApplicationContext;
import io.micronaut.context.annotation.Value;
@@ -201,6 +201,9 @@ public class ExecutionController {
@Inject
private LocalPathFactory localPathFactory;
@Inject
private FileRenderBuilder fileRenderBuilder;
@Value("${" + LocalPath.ENABLE_PREVIEW_CONFIG + ":true}")
private boolean enableLocalFilePreview;
@@ -1869,7 +1872,7 @@ public class ExecutionController {
};
try (fileStream) {
FileRender fileRender = FileRenderBuilder.of(
FileRender fileRender = fileRenderBuilder.of(
extension,
fileStream,
charset,

View File

@@ -1,5 +1,6 @@
package io.kestra.webserver.utils.filepreview;
import io.kestra.plugin.core.preview.FileRender;
import org.apache.commons.io.IOUtils;
import java.io.IOException;

View File

@@ -1,5 +1,6 @@
package io.kestra.webserver.utils.filepreview;
import io.kestra.plugin.core.preview.FileRender;
import lombok.Getter;
import java.io.BufferedReader;

View File

@@ -1,15 +1,40 @@
package io.kestra.webserver.utils.filepreview;
import io.kestra.plugin.core.preview.FileRender;
import io.kestra.plugin.core.preview.PreviewRenderer;
import io.kestra.plugin.core.preview.PreviewRendererFactory;
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
import io.kestra.plugin.core.preview.PreviewResult;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@Singleton
@Slf4j
public class FileRenderBuilder {
private static final Charset DEFAULT_FILE_CHARSET = StandardCharsets.UTF_8;
public static FileRender of(String extension, InputStream filestream, Optional<Charset> charset, Integer maxLine) throws IOException {
@Inject
private PreviewRendererFactory previewRendererFactory;
public FileRender of(String extension, InputStream filestream, Optional<Charset> charset, Integer maxLine) throws IOException {
// we check plugin renderers first
Optional<PreviewRenderer> pluginRenderer = previewRendererFactory.getRenderer(extension);
if (pluginRenderer.isPresent()) {
try {
PreviewResult result = pluginRenderer.get().render(extension, filestream, charset, maxLine);
return convertToFileRender(result);
} catch (Exception e) {
log.warn("Plugin preview renderer failed for extension '{}': {}", extension, e.getMessage());
}
}
if (ImageFileRender.ImageFileExtension.isImageFileExtension(extension)) {
return new ImageFileRender(extension, filestream, maxLine);
}
@@ -21,4 +46,24 @@ public class FileRenderBuilder {
default -> new DefaultFileRender(extension, filestream, charset.orElse(DEFAULT_FILE_CHARSET), maxLine);
};
}
}
private FileRender convertToFileRender(PreviewResult result) {
return new FileRender(result.getExtension(), result.getMaxLines()) {
{
this.type = convertType(result.getType());
this.content = result.getContent();
this.truncated = result.isTruncated();
}
};
}
private FileRender.Type convertType(PreviewResult.Type type) {
return switch (type) {
case TEXT -> FileRender.Type.TEXT;
case LIST -> FileRender.Type.LIST;
case IMAGE -> FileRender.Type.IMAGE;
case MARKDOWN -> FileRender.Type.MARKDOWN;
case PDF -> FileRender.Type.PDF;
};
}
}

View File

@@ -1,6 +1,7 @@
package io.kestra.webserver.utils.filepreview;
import io.kestra.core.serializers.FileSerde;
import io.kestra.plugin.core.preview.FileRender;
import lombok.Getter;
import java.io.BufferedReader;

View File

@@ -1,5 +1,6 @@
package io.kestra.webserver.utils.filepreview;
import jakarta.inject.Inject;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -13,13 +14,16 @@ import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
public class FileRenderBuilderTest {
@Inject
private FileRenderBuilder fileRenderBuilder;
@ParameterizedTest
@MethodSource("provideExtensions")
void of(String extension, Class returnedClass) throws IOException {
var emptyInput = new ByteArrayInputStream("".getBytes());
var charset = StandardCharsets.UTF_8;
assertThat(FileRenderBuilder.of(extension, emptyInput, Optional.of(charset), 1000).getClass()).isEqualTo(returnedClass);
assertThat(fileRenderBuilder.of(extension, emptyInput, Optional.of(charset), 1000).getClass()).isEqualTo(returnedClass);
}
private static Stream<Arguments> provideExtensions() {