소스 검색

feat(webui): preview images in the execution viewer

Taking base64 out of execution records made them 51 times smaller but cost the
ability to see what a run fetched or generated. A reference is a better carrier
than embedded bytes precisely because it can be resolved on demand, so the
viewer now resolves it.

Adds GET /api/v1/files/:id, which streams a stored file with the mime type
recorded against it, and /info for its metadata. The file store has been
readable from nodes since the storage adapter exposed it; this is what lets a
browser show one.

An img tag cannot send a bearer token, so rather than introducing cookie auth
or signed URLs the panel fetches through the existing API client and renders an
object URL, keeping one auth mechanism.

Node output rendering moves into NodeOutputView, used by all three places that
previously stringified output, which shows any image the output refers to above
the JSON rather than in place of it. An object carrying both a stored id and a
source URL renders once, from the stored copy.
fszontagh 1 개월 전
부모
커밋
4817a945eb

+ 1 - 0
CMakeLists.txt

@@ -171,6 +171,7 @@ add_executable(smartbotic-webserver
     src/webserver/api/runner_controller.cpp
     src/webserver/api/webhook_controller.cpp
     src/webserver/api/database_controller.cpp
+    src/webserver/api/file_controller.cpp
 )
 target_include_directories(smartbotic-webserver PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src

+ 84 - 0
src/webserver/api/file_controller.cpp

@@ -0,0 +1,84 @@
+#include "file_controller.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::api {
+
+FileController::FileController(storage::StorageClient& storage,
+                               auth::AuthMiddleware& middleware)
+    : storage_(storage), middleware_(middleware) {}
+
+void FileController::registerRoutes(httplib::Server& server) {
+    // Raw bytes, for rendering a stored file directly
+    server.Get(R"(/api/v1/files/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            getFile(req, res, ctx);
+        });
+    });
+
+    // Metadata only, for deciding whether something is worth rendering
+    server.Get(R"(/api/v1/files/([^/]+)/info)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            getFileInfo(req, res, ctx);
+        });
+    });
+}
+
+void FileController::getFile(const httplib::Request& req, httplib::Response& res,
+                             const auth::AuthContext& ctx) {
+    (void)ctx;
+    const std::string id = req.matches[1];
+
+    // The mime type comes from the record rather than being guessed, so the
+    // browser renders the file as whatever it was stored as.
+    std::string mime_type = "application/octet-stream";
+    auto info = storage_.getFileInfo(id);
+    if (info.ok() && !info.value().mime_type.empty()) {
+        mime_type = info.value().mime_type;
+    }
+
+    auto content = storage_.downloadFile(id);
+    if (content.failed()) {
+        sendError(res, "File not found", 404);
+        return;
+    }
+
+    const auto& bytes = content.value();
+    res.set_content(reinterpret_cast<const char*>(bytes.data()), bytes.size(), mime_type);
+
+    // Stored files are addressed by an immutable id, so they can be cached hard.
+    res.set_header("Cache-Control", "private, max-age=86400");
+}
+
+void FileController::getFileInfo(const httplib::Request& req, httplib::Response& res,
+                                 const auth::AuthContext& ctx) {
+    (void)ctx;
+    const std::string id = req.matches[1];
+
+    auto info = storage_.getFileInfo(id);
+    if (info.failed()) {
+        sendError(res, "File not found", 404);
+        return;
+    }
+
+    const auto& f = info.value();
+    nlohmann::json body = {
+        {"id", f.id},
+        {"name", f.name},
+        {"mimeType", f.mime_type},
+        {"size", f.size},
+        {"fileType", f.file_type},
+        {"relatedId", f.related_id},
+        {"checksum", f.checksum},
+        {"refCount", f.ref_count},
+        {"createdAt", f.created_at}
+    };
+    res.set_content(body.dump(), "application/json");
+}
+
+void FileController::sendError(httplib::Response& res, const std::string& message, int status) {
+    nlohmann::json body = {{"error", message}};
+    res.status = status;
+    res.set_content(body.dump(), "application/json");
+}
+
+} // namespace smartbotic::webserver::api

+ 30 - 0
src/webserver/api/file_controller.hpp

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <httplib.h>
+#include "../auth/auth_middleware.hpp"
+#include "storage/storage_client.hpp"
+
+namespace smartbotic::webserver::api {
+
+// Serves the database file store over HTTP. Nodes have been able to read and
+// write files since the storage adapter exposed them; this is what lets the
+// WebUI show one.
+class FileController {
+public:
+    FileController(storage::StorageClient& storage, auth::AuthMiddleware& middleware);
+
+    void registerRoutes(httplib::Server& server);
+
+private:
+    void getFile(const httplib::Request& req, httplib::Response& res,
+                 const auth::AuthContext& ctx);
+    void getFileInfo(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx);
+
+    void sendError(httplib::Response& res, const std::string& message, int status);
+
+    storage::StorageClient& storage_;
+    auth::AuthMiddleware& middleware_;
+};
+
+} // namespace smartbotic::webserver::api

+ 3 - 0
src/webserver/webserver_service.cpp

@@ -8,6 +8,7 @@
 #include "api/runner_controller.hpp"
 #include "api/webhook_controller.hpp"
 #include "api/database_controller.hpp"
+#include "api/file_controller.hpp"
 #include "api/credential_controller.hpp"
 #include "nodes/node_store.hpp"
 #include "grpc/node_sync_service.hpp"
@@ -213,7 +214,9 @@ void WebServerService::setupRoutes() {
     workflow_group_ctrl_->registerRoutes(server);
 
     execution_ctrl_ = std::make_unique<api::ExecutionController>(*storage_, *auth_middleware_, *ws_server_, *scheduler_);
+    file_ctrl_ = std::make_unique<api::FileController>(*storage_, *auth_middleware_);
     execution_ctrl_->registerRoutes(server);
+    file_ctrl_->registerRoutes(server);
 
     node_ctrl_ = std::make_unique<api::NodeController>(
         *node_store_, *auth_middleware_, &node_sync_server_->service());

+ 2 - 0
src/webserver/webserver_service.hpp

@@ -31,6 +31,7 @@ namespace smartbotic::webserver::api {
     class WorkflowController;
     class WorkflowGroupController;
     class ExecutionController;
+    class FileController;
     class NodeController;
     class RunnerController;
     class WebhookController;
@@ -114,6 +115,7 @@ private:
     std::unique_ptr<api::WorkflowController> workflow_ctrl_;
     std::unique_ptr<api::WorkflowGroupController> workflow_group_ctrl_;
     std::unique_ptr<api::ExecutionController> execution_ctrl_;
+    std::unique_ptr<api::FileController> file_ctrl_;
     std::unique_ptr<api::NodeController> node_ctrl_;
     std::unique_ptr<api::RunnerController> runner_ctrl_;
     std::unique_ptr<api::WebhookController> webhook_ctrl_;

+ 4 - 15
webui/src/components/workflow/ExecutionResultsPanel.tsx

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
 import { Node, Edge } from 'reactflow'
 import { X, ChevronRight, ChevronDown, Loader2, CheckCircle, XCircle, Repeat } from 'lucide-react'
 import { NodeExecutionState } from './WorkflowNode'
+import { NodeOutputView } from './NodeOutputView'
 
 interface ExecutionState {
   executionId: string | null
@@ -376,11 +377,7 @@ export function ExecutionResultsPanel({
                                 {selectedIter.error}
                               </div>
                             ) : selectedIter.output !== undefined ? (
-                              <pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
-                                {typeof selectedIter.output === 'object'
-                                  ? JSON.stringify(selectedIter.output, null, 2)
-                                  : String(selectedIter.output)}
-                              </pre>
+                              <NodeOutputView value={selectedIter.output} />
                             ) : selectedIter.status === 'skipped' ? (
                               <div className="text-xs text-gray-400 dark:text-gray-500 italic">Skipped (condition not met)</div>
                             ) : (
@@ -399,11 +396,7 @@ export function ExecutionResultsPanel({
                         if (!selectedItem) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
 
                         return (
-                          <pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
-                            {typeof selectedItem === 'object'
-                              ? JSON.stringify(selectedItem, null, 2)
-                              : String(selectedItem)}
-                          </pre>
+                          <NodeOutputView value={selectedItem} />
                         )
                       })()}
                     </div>
@@ -412,11 +405,7 @@ export function ExecutionResultsPanel({
                       {nodeState.error}
                     </div>
                   ) : nodeState.output !== undefined ? (
-                    <pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
-                      {typeof nodeState.output === 'object'
-                        ? JSON.stringify(nodeState.output, null, 2)
-                        : String(nodeState.output)}
-                    </pre>
+                    <NodeOutputView value={nodeState.output} />
                   ) : (
                     <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
                   )}

+ 131 - 0
webui/src/components/workflow/NodeOutputView.tsx

@@ -0,0 +1,131 @@
+import { useEffect, useState } from 'react'
+import { ImageOff } from 'lucide-react'
+import { api } from '../../api/client'
+
+// A picture an execution produced, referenced rather than embedded. Node outputs
+// no longer carry base64 - they carry a file-store id, or the address the image
+// came from - so the viewer resolves the reference when you look at that node.
+interface ImageRef {
+  key: string
+  fileId?: string
+  url?: string
+}
+
+const IMAGE_URL = /\.(png|jpe?g|gif|webp|avif|bmp)(\?|$)/i
+
+function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): ImageRef[] {
+  if (!value || typeof value !== 'object' || depth > 4 || found.length >= 4) {
+    return found
+  }
+
+  if (Array.isArray(value)) {
+    for (const item of value) collectImageRefs(item, depth + 1, found)
+    return found
+  }
+
+  const obj = value as Record<string, any>
+
+  // One object describes one image. When it carries a stored id as well as the
+  // address it came from, the stored copy wins: it is the one this system owns,
+  // and showing both renders the same picture twice.
+  if (typeof obj.fileId === 'string' && obj.fileId.length > 0) {
+    found.push({ key: obj.fileId, fileId: obj.fileId })
+  } else {
+    for (const field of ['sourceUrl', 'url']) {
+      const candidate = obj[field]
+      if (typeof candidate === 'string' && IMAGE_URL.test(candidate)) {
+        found.push({ key: candidate, url: candidate })
+        break
+      }
+    }
+  }
+
+  for (const nested of Object.values(obj)) {
+    collectImageRefs(nested, depth + 1, found)
+  }
+
+  return found
+}
+
+// Stored files sit behind the authenticated API, and an img tag cannot send the
+// bearer token, so the bytes are fetched through the API client and handed to
+// the tag as an object URL.
+function StoredImage({ fileId }: { fileId: string }) {
+  const [src, setSrc] = useState<string | null>(null)
+  const [failed, setFailed] = useState(false)
+
+  useEffect(() => {
+    let objectUrl: string | null = null
+    let cancelled = false
+
+    api
+      .get(`/files/${fileId}`, { responseType: 'blob' })
+      .then((response) => {
+        if (cancelled) return
+        objectUrl = URL.createObjectURL(response.data)
+        setSrc(objectUrl)
+      })
+      .catch(() => {
+        if (!cancelled) setFailed(true)
+      })
+
+    return () => {
+      cancelled = true
+      if (objectUrl) URL.revokeObjectURL(objectUrl)
+    }
+  }, [fileId])
+
+  if (failed) {
+    return (
+      <div className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
+        <ImageOff className="w-3.5 h-3.5" />
+        File no longer stored
+      </div>
+    )
+  }
+
+  if (!src) {
+    return <div className="h-24 w-24 rounded bg-gray-100 dark:bg-slate-700 animate-pulse" />
+  }
+
+  return <Preview src={src} title={`file ${fileId}`} />
+}
+
+function Preview({ src, title }: { src: string; title: string }) {
+  const [failed, setFailed] = useState(false)
+  if (failed) return null
+
+  return (
+    <a href={src} target="_blank" rel="noreferrer" title={title}>
+      <img
+        src={src}
+        alt={title}
+        onError={() => setFailed(true)}
+        className="h-24 w-24 object-cover rounded border border-gray-200 dark:border-slate-700 hover:opacity-90"
+      />
+    </a>
+  )
+}
+
+export function NodeOutputView({ value }: { value: unknown }) {
+  const refs = collectImageRefs(value)
+
+  return (
+    <div className="space-y-2">
+      {refs.length > 0 && (
+        <div className="flex flex-wrap gap-2">
+          {refs.map((ref) =>
+            ref.fileId ? (
+              <StoredImage key={ref.key} fileId={ref.fileId} />
+            ) : (
+              <Preview key={ref.key} src={ref.url!} title={ref.url!} />
+            )
+          )}
+        </div>
+      )}
+      <pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
+        {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
+      </pre>
+    </div>
+  )
+}