Repository: dagu-org/dagu

Issue:
Logging full stdout in cli and gui
Hi. First - fantastic piece of software! 

Question/Feature request? 

When invoked via cli, I'd like to see the output of the steps. Succesful or failed. Can't seem to find anything. Now I have to run the dag via cli and then manually look in the log files. Is there something I've misesd?

Also, is it possible to add a full output to the gui? Now I have to click the respective step to see the output

My plan is to essentially replace github actions with dagu (but locally) 🍺

All the best
Johan

Discussion:
@yottahmd: Hi @borestad, thank you for your kind words!

> When invoked via CLI, I'd like to see the output of the steps  

Do you mean you’d like all step logs to appear directly in stdout and stderr? That’s possible, though if multiple steps run in parallel, the output may become difficult to read. If you have any ideas or preferred approaches, I’d be happy to hear them.

> Also, is it possible to add a full output to the GUI? Now I have to click each step to see the output  

When you mention “output,” are you referring to stdout, or the captured stdout stored in a named `output` variable? An example would be very helpful.

> My plan is to essentially replace GitHub Actions with Dagu (but locally) 🍺  

That sounds very interesting! What motivated you to switch from GitHub Actions to a local Dagu setup?@borestad: Regarding the cli. I'd like to somehow at least be able to catch the logs visually. In the case of async/parallel logs, it would be totally fine to just wait until the entire run is finished. Just lacking some output here (stdout/stderr, maybe both)

<img width="869" height="262" alt="Image" src="https://github.com/user-attachments/assets/856bc2f3-1fe3-4441-9089-548ac5135dfe" />

The same essentially goes for the UI. I'd like to read a summary of all steps. Similar to how I'd look for the result/outcome if using i.e Github Action. 
Regarding if it should capture whats in stdout. I think I'd like that PLUS a raw mode - similar to Github's raw output logs :-) 

The reason for using Dagu conceptually as a local Github Action is due to certain companies/private projects I work for can't use external cloud services. And also the ability to easily spin up a single binary service locally when needed is great!
I see dagu as a one swiss army knife binary for tasks/crons - And with the portability of the yaml tasks files, it is a gold mine! 🍺

Sure there are other/multiple ways of running tasks locally, but most of them lack the ability to also run cron/scheduled tasks. And to have a cleaned standardise way of running DAG's is great for reusability.

The cli ux is also great for some of my non-techy collagues, and to also have a web ui is just sugar on top! 


@yottahmd: Thanks for the explanation! Everything you mentioned makes sense.

I have a few questions about "summary of all steps". I might be mistaken, but it seems that the GitHub Action results don’t include a summary of the logs. Could you clarify this a bit more?

Here’s an example GitHub Actions result for reference:
<img width="1694" height="1158" alt="Image" src="https://github.com/user-attachments/assets/66b3a769-f798-4e67-87c3-c14350b3a449" />@borestad: Ah...of course, I'll try to explain  :-)

I think it might be ux related - but I can easily expand all jobs on the same page without loosing context.

What I actually want to achieve is to quickly get a high level understanding of what actually happened on the respective task that has been run. Similar to tailing a log. Just a lot of output without too much clicks. I rather scroll to find what I'm after 🙂


<img width="1096" height="746" alt="Image" src="https://github.com/user-attachments/assets/05ec6a8d-560d-4d8b-962e-0500837325c6" />

Compared to the dagu webui where I have to click on stdout/stderr. A popups opens, then click on close.

<img width="1083" height="237" alt="Image" src="https://github.com/user-attachments/assets/4f69a1e2-18f9-4255-a939-eefdf468ee86" />

<img width="1237" height="786" alt="Image" src="https://github.com/user-attachments/assets/d6a902ba-71b1-4dc7-8a88-03411d8f62b9" />

And then the ability to look at the raw logs if needed:

<img width="635" height="541" alt="Image" src="https://github.com/user-attachments/assets/ff505a73-4159-4306-8673-3d3e6336e458" />@ghansham: @yottahmd github actions look more like subdags with steps.@yottahmd: Thanks for the clarification and for the great idea! It seems the Web UI part would be fairly straightforward, while the CLI might need a bit more thinking and research (e.g., handling a large log file, mixing stderr and stdout, etc). Let's start by improving the Web UI first.@codinggeeks06: hi @yottahmd,
in cli logs I guess we can do it using bubbletea TUI ig. What do you think?
@yottahmd: Hi @codinggeeks06 
Yes, I agree. We could use bubble tea to implement useful navigation between steps and logs (perhaps switch to `less` or something?) inside the CLI. Even if the experience isn’t exactly like GitHub Actions, it would be very useful.@codinggeeks06: ill try to implement this if possible. if possible please assign this to @kriyanshii . @borestad: Wow! You guys are great! ❤️@yottahmd: Hi @borestad, we've released a new version that includes in-line log viewer (like GitHub Actions). Give it a try. Thanks!@borestad: Fantastic. And what a release! With Github Actions as well. Haha, I didn't know beforehand that you were planning this. Such a great coincidence 😃

Thanks guys. Dagu is really a gem in the open source world! ❤️  @kriyanshii: I think this works, can we close this?@borestad: Yes box!!@yottahmd: Thank you very much!

Solution patch:
diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md
index 3d5e51c4..0b3aedec 100644
--- a/docs/reference/changelog.md
+++ b/docs/reference/changelog.md
@@ -21,11 +21,14 @@
 - Parameters: Added JSON Schema validation mode with `schema`.
 - Runtime: Injects `DAG_RUN_STATUS` into handler environments so exit/success/failure/cancel scripts can branch on the final canonical status.
 - Executors: Added an experimental GitHub Actions executor (`type: gha`) powered by nektos/act; action inputs come from the new step-level `params` map.
+- UI: Added accordion-style expandable node rows to display step logs inline, similar to GitHub Actions, reducing the need to open popup windows (#1313).
 
 ### Fixed
 - DAG name validation is centralized and enforced consistently: names must be `<= 40` chars and match `[A-Za-z0-9_.-]+`. Endpoints that accept `name` now return `400 bad_request` for invalid names.
 - Docker: Fixed container initialization bug with `registryAuths` field (#1330)
 - Windows: Fixed process cancellation not terminating subprocesses by recursively killing all child processes (#1342)
+- UI: Fixed duration display update bug in DAG run details
+- Other small issues and improvements
 
 ### Contributors
 
@@ -35,6 +38,7 @@ Thanks to our contributors for this release:
 | -------------------------------------- | ---------------------------------------- |
 | Docker-in-Docker container execution issues (#1228, #1231, #1235) and registryAuths bug report (#1327) | [@bellackn](https://github.com/bellackn) |
 | Container name support (#1237), bash requirement (#1239), command field (#1261), log buttons (#1301), and scroll issues (#1324) | [@Pangolin2097](https://github.com/Pangolin2097) |
+| Accordion-style log expansion feature request (#1313) | [@borestad](https://github.com/borestad) |
 | SSH environment variables feature request (#1238) | [@n3storm](https://github.com/n3storm) |
 | SSH config override issue report (#1249) | [@TrezOne](https://github.com/TrezOne) |
 | DAG dependency resolution error report (#1262) | [@JuchangGit](https://github.com/JuchangGit) |
diff --git a/internal/cmd/context.go b/internal/cmd/context.go
index 50f4c47a..4a426d40 100644
--- a/internal/cmd/context.go
+++ b/internal/cmd/context.go
@@ -8,7 +8,6 @@ import (
 	"path/filepath"
 	"regexp"
 	"runtime/pprof"
-	"sync"
 	"syscall"
 	"time"
 
@@ -31,7 +30,6 @@ import (
 	"github.com/dagu-org/dagu/internal/service/scheduler"
 	"github.com/google/uuid"
 	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
 )
 
 // Context holds the configuration for a command.
@@ -93,8 +91,12 @@ func NewContext(cmd *cobra.Command, flags []commandLineFlag) (*Context, error) {
 		}
 	}
 
-	// Use a custom config file if provided via the viper flag "config"
-	if cfgPath := viper.GetString("config"); cfgPath != "" {
+	// Use a custom config file if provided via the command flag "config"
+	cfgPath, err := cmd.Flags().GetString("config")
+	if err != nil {
+		return nil, fmt.Errorf("failed to get config flag: %w", err)
+	}
+	if cfgPath != "" {
 		configLoaderOpts = append(configLoaderOpts, config.WithConfigFile(cfgPath))
 	}
 
@@ -306,11 +308,11 @@ func (c *Context) GenLogFileName(dag *core.DAG, dagRunID string) (string, error)
 	return filepath.Join(d, cfg.LogFile()), nil
 }
 
-var viperLock sync.Mutex // protects viper access across commands
-
 // NewCommand creates a new command instance with the given cobra command and run function.
 func NewCommand(cmd *cobra.Command, flags []commandLineFlag, runFunc func(cmd *Context, args []string) error) *cobra.Command {
-	initFlags(cmd, flags...)
+	config.WithViperLock(func() {
+		initFlags(cmd, flags...)
+	})
 
 	cmd.RunE = func(cmd *cobra.Command, args []string) error {
 		// Setup cpu profiling if enabled.
@@ -329,9 +331,7 @@ func NewCommand(cmd *cobra.Command, flags []commandLineFlag, runFunc func(cmd *C
 			}()
 		}
 
-		viperLock.Lock()
 		ctx, err := NewContext(cmd, flags)
-		viperLock.Unlock()
 
 		if err != nil {
 			fmt.Printf("Initialization error: %v\n", err)
diff --git a/internal/cmd/flags.go b/internal/cmd/flags.go
index 88dc9d8b..ef063843 100644
--- a/internal/cmd/flags.go
+++ b/internal/cmd/flags.go
@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"github.com/dagu-org/dagu/internal/common/config"
 	"github.com/dagu-org/dagu/internal/common/stringutil"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
@@ -270,9 +271,11 @@ func initFlags(cmd *cobra.Command, additionalFlags ...commandLineFlag) {
 func bindFlags(cmd *cobra.Command, additionalFlags ...commandLineFlag) {
 	flags := append([]commandLineFlag{configFlag}, additionalFlags...)
 
-	for _, flag := range flags {
-		if flag.bindViper {
-			_ = viper.BindPFlag(stringutil.KebabToCamel(flag.name), cmd.Flags().Lookup(flag.name))
+	config.WithViperLock(func() {
+		for _, flag := range flags {
+			if flag.bindViper {
+				_ = viper.BindPFlag(stringutil.KebabToCamel(flag.name), cmd.Flags().Lookup(flag.name))
+			}
 		}
-	}
+	})
 }
diff --git a/internal/common/config/loader.go b/internal/common/config/loader.go
index d2bd2e30..031719c7 100644
--- a/internal/common/config/loader.go
+++ b/internal/common/config/loader.go
@@ -8,7 +8,6 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
-	"sync"
 	"time"
 
 	"github.com/adrg/xdg"
@@ -16,14 +15,11 @@ import (
 	"github.com/spf13/viper"
 )
 
-// loadLock synchronizes access to the Load function to ensure that only one configuration load occurs at a time.
-var loadLock sync.Mutex
-
 // Load creates a new configuration by instantiating a ConfigLoader with the provided options
 // and then invoking its Load method.
 func Load(opts ...ConfigLoaderOption) (*Config, error) {
-	loadLock.Lock()
-	defer loadLock.Unlock()
+	lockViper()
+	defer unlockViper()
 
 	loader := NewConfigLoader(opts...)
 	cfg, err := loader.Load()
@@ -34,9 +30,7 @@ func Load(opts ...ConfigLoaderOption) (*Config, error) {
 }
 
 // ConfigLoader is responsible for reading and merging configuration from various sources.
-// The internal mutex ensures thread-safety when loading the configuration.
 type ConfigLoader struct {
-	lock              sync.Mutex
 	configFile        string   // Optional explicit path to the configuration file.
 	warnings          []string // Collected warnings during configuration resolution.
 	additionalBaseEnv []string // Additional environment variables to append to the base environment.
@@ -71,10 +65,8 @@ func NewConfigLoader(options ...ConfigLoaderOption) *ConfigLoader {
 
 // Load initializes viper, reads configuration files, handles legacy configuration,
 // and returns a fully built and validated Config instance.
+// Note: This method must be called while holding the global viper lock (via config.Load()).
 func (l *ConfigLoader) Load() (*Config, error) {
-	l.lock.Lock()
-	defer l.lock.Unlock()
-
 	// Initialize viper with proper defaults, environment binding and warnings.
 	homeDir, err := getHomeDir()
 	if err != nil {
diff --git a/internal/common/config/lock.go b/internal/common/config/lock.go
new file mode 100644
index 00000000..f5da0a6e
--- /dev/null
+++ b/internal/common/config/lock.go
@@ -0,0 +1,33 @@
+package config
+
+import "sync"
+
+// globalViperMu serializes all access to the shared viper instance used across the application.
+// Uses RWMutex to allow concurrent reads while serializing writes.
+var globalViperMu sync.RWMutex
+
+// lockViper acquires the global viper write lock (private).
+func lockViper() {
+	globalViperMu.Lock()
+}
+
+// unlockViper releases the global viper write lock (private).
+func unlockViper() {
+	globalViperMu.Unlock()
+}
+
+// WithViperLock runs the provided function while holding the global viper write lock.
+// Use this for any operations that modify viper state (Set, BindPFlag, ReadInConfig, etc.).
+func WithViperLock(fn func()) {
+	lockViper()
+	defer unlockViper()
+	fn()
+}
+
+// WithViperRLock runs the provided function while holding the global viper read lock.
+// Use this for read-only operations (Get, IsSet, etc.) to allow concurrent access.
+func WithViperRLock(fn func()) {
+	globalViperMu.RLock()
+	defer globalViperMu.RUnlock()
+	fn()
+}
diff --git a/internal/runtime/executor/dag_runner.go b/internal/runtime/executor/dag_runner.go
index 0287e7b0..2332dd6d 100644
--- a/internal/runtime/executor/dag_runner.go
+++ b/internal/runtime/executor/dag_runner.go
@@ -129,12 +129,12 @@ func (e *ChildDAGExecutor) buildCommand(
 	traceEnvVars := extractTraceContext(ctx)
 	if len(traceEnvVars) > 0 {
 		cmd.Env = append(cmd.Env, traceEnvVars...)
-		logger.Info(ctx, "Injecting trace context into child DAG",
+		logger.Debug(ctx, "Injecting trace context into child DAG",
 			"traceEnvVars", traceEnvVars,
 			"childDAG", e.DAG.Name,
 		)
 	} else {
-		logger.Warn(ctx, "No trace context to inject into child DAG",
+		logger.Debug(ctx, "No trace context to inject into child DAG",
 			"childDAG", e.DAG.Name,
 		)
 	}
diff --git a/internal/service/frontend/api/v2/dagruns.go b/internal/service/frontend/api/v2/dagruns.go
index 0dcf4bd7..f240ebc4 100644
--- a/internal/service/frontend/api/v2/dagruns.go
+++ b/internal/service/frontend/api/v2/dagruns.go
@@ -149,7 +149,11 @@ func (a *API) ExecuteDAGRunFromSpec(ctx context.Context, request api.ExecuteDAGR
 	}
 
 	if err := a.startDAGRun(ctx, dag, params, dagRunId, singleton); err != nil {
-		return nil, fmt.Errorf("error starting dag-run: %w", err)
+		return nil, &Error{
+			HTTPStatus: http.StatusInternalServerError,
+			Code:       api.ErrorCodeInternalError,
+			Message:    fmt.Sprintf("failed to start dag-run: %s", err.Error()),
+		}
 	}
 
 	return api.ExecuteDAGRunFromSpec200JSONResponse{
diff --git a/internal/service/frontend/api/v2/dags.go b/internal/service/frontend/api/v2/dags.go
index 3d4cc91e..6a6bd6a5 100644
--- a/internal/service/frontend/api/v2/dags.go
+++ b/internal/service/frontend/api/v2/dags.go
@@ -634,7 +634,7 @@ func (a *API) startDAGRun(ctx context.Context, dag *core.DAG, params, dagRunID s
 
 	// Wait for the DAG to start
 	// Use longer timeout on Windows due to slower process startup
-	timeout := 3 * time.Second
+	timeout := 5 * time.Second // default timeout
 	if runtime.GOOS == "windows" {
 		timeout = 10 * time.Second
 	}
diff --git a/ui/src/features/dags/components/dag-details/DAGHeader.tsx b/ui/src/features/dags/components/dag-details/DAGHeader.tsx
index 9c0ab282..2932770a 100644
--- a/ui/src/features/dags/components/dag-details/DAGHeader.tsx
+++ b/ui/src/features/dags/components/dag-details/DAGHeader.tsx
@@ -1,7 +1,7 @@
 import { Calendar, Terminal, Timer, RefreshCw } from 'lucide-react';
 import React, { useEffect } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
-import { components } from '../../../../api/v2/schema';
+import { components, Status } from '../../../../api/v2/schema';
 import dayjs from '../../../../lib/dayjs';
 import StatusChip from '../../../../ui/StatusChip';
 import { RootDAGRunContext } from '../../contexts/RootDAGRunContext';
@@ -28,10 +28,54 @@ const DAGHeader: React.FC<DAGHeaderProps> = ({
   const params = useParams<{ tab?: string }>();
   const rootDAGRunContext = React.useContext(RootDAGRunContext);
   const [isRefreshing, setIsRefreshing] = React.useState(false);
+  const [currentDuration, setCurrentDuration] = React.useState<string>('--');
 
   // Use the DAG-run from context if available, otherwise use the prop
   const dagRunToDisplay = rootDAGRunContext.data || currentDAGRun;
 
+  // Calculate duration between start and end times
+  const calculateDuration = React.useCallback(() => {
+    if (!dagRunToDisplay.startedAt || dagRunToDisplay.startedAt === '-') {
+      return '--';
+    }
+
+    const end =
+      dagRunToDisplay.finishedAt && dagRunToDisplay.finishedAt !== '-'
+        ? dayjs(dagRunToDisplay.finishedAt)
+        : dayjs();
+
+    const start = dayjs(dagRunToDisplay.startedAt);
+    const diff = end.diff(start, 'second');
+
+    const hours = Math.floor(diff / 3600);
+    const minutes = Math.floor((diff % 3600) / 60);
+    const seconds = diff % 60;
+
+    return `${hours > 0 ? `${hours}h ` : ''}${minutes > 0 ? `${minutes}m ` : ''}${seconds}s`;
+  }, [dagRunToDisplay.startedAt, dagRunToDisplay.finishedAt]);
+
+  // Determine if the DAG is currently running
+  const isRunning = dagRunToDisplay.status === Status.Running;
+
+  // Auto-update duration every second for running DAGs
+  useEffect(() => {
+    if (isRunning && dagRunToDisplay.startedAt) {
+      // Initial calculation
+      setCurrentDuration(calculateDuration());
+
+      // Set up interval to update duration every second
+      const intervalId = setInterval(() => {
+        setCurrentDuration(calculateDuration());
+      }, 1000);
+
+      // Clean up interval on unmount or when status changes
+      return () => clearInterval(intervalId);
+    } else {
+      // For non-running DAGs, calculate once
+      setCurrentDuration(calculateDuration());
+    }
+  }, [isRunning, dagRunToDisplay.startedAt, dagRunToDisplay.finishedAt]);
+
   const handleRootDAGRunClick = (e: React.MouseEvent) => {
     e.preventDefault();
     navigate(
@@ -184,18 +228,11 @@ const DAGHeader: React.FC<DAGHeaderProps> = ({
 
               <div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 bg-slate-200 dark:bg-slate-700 rounded-md px-3 py-1.5 border">
                 <Timer className="h-4 w-4 text-slate-600 dark:text-slate-400" />
-                <span className="font-medium text-xs">
-                  {dagRunToDisplay.finishedAt
-                    ? formatDuration(
-                        dagRunToDisplay.startedAt,
-                        dagRunToDisplay.finishedAt
-                      )
-                    : dagRunToDisplay.startedAt
-                      ? formatDuration(
-                          dagRunToDisplay.startedAt,
-                          dayjs().toISOString()
-                        )
-                      : '--'}
+                <span className="font-medium text-xs flex items-center gap-1">
+                  {currentDuration}
+                  {isRunning && (
+                    <span className="inline-block w-1.5 h-1.5 rounded-full bg-lime-500 animate-pulse" />
+                  )}
                 </span>
               </div>
 
diff --git a/ui/src/features/dags/components/dag-details/DAGStatusOverview.tsx b/ui/src/features/dags/components/dag-details/DAGStatusOverview.tsx
index a4fcb0a3..d2fdea7c 100644
--- a/ui/src/features/dags/components/dag-details/DAGStatusOverview.tsx
+++ b/ui/src/features/dags/components/dag-details/DAGStatusOverview.tsx
@@ -16,6 +16,7 @@ import {
   Terminal,
   Timer,
 } from 'lucide-react';
+import React from 'react';
 import { components, Status } from '../../../../api/v2/schema';
 import LabeledItem from '../../../../ui/LabeledItem';
 import StatusChip from '../../../../ui/StatusChip';
@@ -51,6 +52,9 @@ function DAGStatusOverview({
   }
   const url = `/dags/${fileName}/dagRun-log?${searchParams.toString()}`;
 
+  // State to store current duration for live updates
+  const [currentDuration, setCurrentDuration] = React.useState<string>('-');
+
   // Don't render if no status is provided
   if (!status) {
     return null;
@@ -88,6 +92,25 @@ function DAGStatusOverview({
   // Determine if the DAG is currently running
   const isRunning = status.status === Status.Running;
 
+  // Auto-update duration every second for running DAGs
+  React.useEffect(() => {
+    if (isRunning && status.startedAt) {
+      // Initial calculation
+      setCurrentDuration(calculateDuration());
+
+      // Set up interval to update duration every second
+      const intervalId = setInterval(() => {
+        setCurrentDuration(calculateDuration());
+      }, 1000);
+
+      // Clean up interval on unmount or when status changes
+      return () => clearInterval(intervalId);
+    } else {
+      // For non-running DAGs, calculate once
+      setCurrentDuration(calculateDuration());
+    }
+  }, [isRunning, status.startedAt, status.finishedAt]);
+
   // Count nodes by status
   const nodeStatus = status.nodes?.reduce(
     (acc, node) => {
@@ -226,8 +249,11 @@ function DAGStatusOverview({
           <div className="flex items-center">
             <Timer className="w-3.5 mr-1 text-slate-500 dark:text-slate-400" />
             <LabeledItem label="Duration">
-              <span className="font-medium text-slate-700 dark:text-slate-300 text-xs">
-                {calculateDuration()}
+              <span className="font-medium text-slate-700 dark:text-slate-300 text-xs flex items-center gap-1">
+                {currentDuration}
+                {isRunning && (
+                  <span className="inline-block w-1.5 h-1.5 rounded-full bg-lime-500 animate-pulse" />
+                )}
               </span>
             </LabeledItem>
           </div>
diff --git a/ui/src/features/dags/components/dag-details/NodeStatusTableRow.tsx b/ui/src/features/dags/components/dag-details/NodeStatusTableRow.tsx
index 6615d4e3..7c975114 100644
--- a/ui/src/features/dags/components/dag-details/NodeStatusTableRow.tsx
+++ b/ui/src/features/dags/components/dag-details/NodeStatusTableRow.tsx
@@ -27,8 +27,9 @@ import { components, NodeStatus } from '../../../../api/v2/schema';
 import StyledTableRow from '../../../../ui/StyledTableRow';
 import { NodeStatusChip } from '../common';
 import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/ui/CustomDialog';
-import { useClient } from '@/hooks/api';
+import { useClient, useQuery } from '@/hooks/api';
 import { AppBarContext } from '@/contexts/AppBarContext';
+import StatusUpdateModal from '../dag-execution/StatusUpdateModal';
 
 /**
  * Props for the NodeStatusTableRow component
@@ -60,6 +61,112 @@ const formatTimestamp = (timestamp: string | undefined) => {
   }
 };
 
+/**
+ * ANSI color codes regex for stripping
+ */
+const ANSI_CODES_REGEX = [
+  '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
+  '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
+].join('|');
+
+/**
+ * Simple inline log viewer - no controls, just logs
+ */
+function InlineLogViewer({
+  dagName,
+  dagRunId,
+  stepName,
+  stream,
+  dagRun,
+}: {
+  dagName: string;
+  dagRunId: string;
+  stepName: string;
+  stream: 'stdout' | 'stderr';
+  dagRun?: components['schemas']['DAGRunDetails'];
+}) {
+  const appBarContext = useContext(AppBarContext);
+  const remoteNode = appBarContext.selectedRemoteNode || 'local';
+
+  // Determine if this is a child DAG run
+  const isChildDAGRun =
+    dagRun &&
+    dagRun.rootDAGRunId &&
+    dagRun.rootDAGRunId !== dagRun.dagRunId;
+
+  // Determine the API endpoint
+  const apiEndpoint = isChildDAGRun
+    ? '/dag-runs/{name}/{dagRunId}/children/{childDAGRunId}/steps/{stepName}/log'
+    : '/dag-runs/{name}/{dagRunId}/steps/{stepName}/log';
+
+  // Prepare path parameters
+  const pathParams = isChildDAGRun
+    ? {
+        name: dagRun.rootDAGRunName,
+        dagRunId: dagRun.rootDAGRunId,
+        childDAGRunId: dagRun.dagRunId,
+        stepName,
+      }
+    : {
+        name: dagName,
+        dagRunId,
+        stepName,
+      };
+
+  // Fetch last 100 lines
+  const { data, isLoading } = useQuery(
+    apiEndpoint,
+    {
+      params: {
+        query: {
+          remoteNode,
+          stream,
+          tail: 100,
+        },
+        path: pathParams,
+      },
+    },
+    {
+      refreshInterval: 2000, // Auto-refresh every 2s
+      revalidateOnFocus: false,
+    }
+  );
+
+  // Process log content
+  const content = data?.content?.replace(new RegExp(ANSI_CODES_REGEX, 'g'), '') || '';
+  const lines = content ? content.split('\n') : [];
+  const totalLines = data?.totalLines || 0;
+  const lineCount = data?.lineCount || 0;
+
+  return (
+    <div className="bg-zinc-900 rounded overflow-hidden">
+      {isLoading && !data ? (
+        <div className="text-zinc-400 text-xs py-4 px-3">Loading logs...</div>
+      ) : lines.length === 0 ? (
+        <div className="text-zinc-400 text-xs py-4 px-3">&lt;No log output&gt;</div>
+      ) : (
+        <div className="overflow-x-auto max-h-[400px] overflow-y-auto">
+          <pre className="font-mono text-[11px] text-zinc-100 p-2">
+            {lines.map((line, index) => {
+              const lineNumber = totalLines - lineCount + index + 1;
+              return (
+                <div key={index} className="flex hover:bg-zinc-800 px-1 py-0.5">
+                  <span className="text-zinc-500 mr-3 select-none w-12 text-right flex-shrink-0">
+                    {lineNumber}
+                  </span>
+                  <span className="whitespace-pre-wrap break-all flex-grow">
+                    {line || ' '}
+                  </span>
+                </div>
+              );
+            })}
+          </pre>
+        </div>
+      )}
+    </div>
+  );
+}
+
 /**
  * Calculate duration between two timestamps
  * If endTime is not provided, calculate duration from startTime to now (for running tasks)
@@ -121,6 +228,11 @@ function NodeStatusTableRow({
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [success, setSuccess] = useState(false);
+  // State for inline log expansion
+  const [isLogExpanded, setIsLogExpanded] = useState(false);
+  const [activeLogTab, setActiveLogTab] = useState<'stdout' | 'stderr'>('stdout');
+  // State for status update modal
+  const [showStatusModal, setShowStatusModal] = useState(false);
   // Check if this is a child dagRun node
   // Include both regular children and repeated children
   const allChildren = [...(node.children || []), ...(node.childrenRepeated || [])];
@@ -264,7 +376,7 @@ function NodeStatusTableRow({
     setError(null);
     try {
       await client.POST('/dag-runs/{name}/{dagRunId}/retry', {
-        params: { 
+        params: {
           path: { name: dagName, dagRunId },
           query: { remoteNode }
         },
@@ -280,19 +392,97 @@ function NodeStatusTableRow({
     }
   };
 
+  // Handle status update
+  const handleStatusUpdate = async (
+    step: components['schemas']['Step'],
+    status: NodeStatus
+  ) => {
+    // Check if this is a child DAG-run
+    const isChildDAGRun =
+      dagRun.rootDAGRunId &&
+      dagRun.rootDAGRunName &&
+      dagRun.rootDAGRunId !== dagRun.dagRunId;
+
+    // Define path parameters
+    const pathParams = {
+      name: isChildDAGRun ? dagRun.rootDAGRunName : dagName,
+      dagRunId: isChildDAGRun ? dagRun.rootDAGRunId : dagRunId || '',
+      stepName: step.name,
+      ...(isChildDAGRun ? { childDAGRunId: dagRun.dagRunId } : {}),
+    };
+
+    // Use the appropriate endpoint
+    const endpoint = isChildDAGRun
+      ? '/dag-runs/{name}/{dagRunId}/children/{childDAGRunId}/steps/{stepName}/status'
+      : '/dag-runs/{name}/{dagRunId}/steps/{stepName}/status';
+
+    const { error } = await client.PATCH(endpoint, {
+      params: {
+        path: pathParams,
+        query: {
+          remoteNode,
+        },
+      },
+      body: {
+        status,
+      },
+    });
+
+    if (error) {
+      alert(error.message || 'An error occurred');
+      return;
+    }
+
+    setShowStatusModal(false);
+  };
+
+  // Determine if logs are available
+  const hasStdout = !!node.stdout;
+  const hasStderr = !!node.stderr;
+  const hasLogs = hasStdout || hasStderr;
+
+  // Determine which stream to show based on active tab
+  const currentStream = activeLogTab === 'stderr' && hasStderr ? 'stderr' : 'stdout';
+
   // Render desktop view (table row)
   if (view === 'desktop') {
     return (
-      <StyledTableRow
-        className={cn(
-          'hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors duration-200 h-auto',
-          getRowHighlight()
-        )}
-      >
+      <>
+        <StyledTableRow
+          className={cn(
+            'hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors duration-200 h-auto cursor-pointer',
+            getRowHighlight()
+          )}
+          onClick={() => {
+            if (hasLogs) {
+              setIsLogExpanded(!isLogExpanded);
+              // Set default tab based on what's available
+              if (!isLogExpanded) {
+                setActiveLogTab(hasStdout ? 'stdout' : 'stderr');
+              }
+            }
+          }}
+        >
         <TableCell className="text-center py-2">
-          <span className="font-semibold text-slate-700 dark:text-slate-300 text-xs">
-            {rownum}
-          </span>
+          <div className="flex items-center justify-center gap-2">
+            {hasLogs && (
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setIsLogExpanded(!isLogExpanded);
+                  if (!isLogExpanded) {
+                    setActiveLogTab(hasStdout ? 'stdout' : 'stderr');
+                  }
+                }}
+                className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
+              >
+                {isLogExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
+              </button>
+            )}
+            <span className="font-semibold text-slate-700 dark:text-slate-300 text-xs">
+              {rownum}
+            </span>
+          </div>
         </TableCell>
 
         {/* Combined Step Name & Description */}
@@ -368,7 +558,10 @@ function NodeStatusTableRow({
                   <>
                     <div
                       className="text-xs text-blue-500 dark:text-blue-400 font-medium cursor-pointer hover:underline"
-                      onClick={(e) => handleChildDAGRunNavigation(0, e)}
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        handleChildDAGRunNavigation(0, e);
+                      }}
                       title="Click to view child DAG run (Cmd/Ctrl+Click to open in new tab)"
                     >
                       View Child DAG Run: {node.step.run}
@@ -388,7 +581,10 @@ function NodeStatusTableRow({
                     <div className="text-xs text-slate-600 dark:text-slate-400 mt-1">
                       <div className="flex items-center gap-1">
                         <button
-                          onClick={() => setIsExpanded(!isExpanded)}
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            setIsExpanded(!isExpanded);
+                          }}
                           className="flex items-center gap-1 text-blue-500 dark:text-blue-400 font-medium hover:underline"
                         >
                           {isExpanded ? (
@@ -406,9 +602,10 @@ function NodeStatusTableRow({
                             <div key={child.dagRunId} className="py-1">
                               <div
                                 className="text-xs text-blue-500 dark:text-blue-400 cursor-pointer hover:underline"
-                                onClick={(e) =>
-                                  handleChildDAGRunNavigation(index, e)
-                                }
+                                onClick={(e) => {
+                                  e.stopPropagation();
+                                  handleChildDAGRunNavigation(index, e);
+                                }}
                                 title="Click to view child DAG run (Cmd/Ctrl+Click to open in new tab)"
                               >
                                 #{index + 1}: {node.step.run}
@@ -464,9 +661,18 @@ function NodeStatusTableRow({
 
         {/* Status */}
         <TableCell className="text-center">
-          <NodeStatusChip status={node.status} size="sm">
-            {node.statusLabel}
-          </NodeStatusChip>
+          <div
+            onClick={(e) => {
+              e.stopPropagation();
+              setShowStatusModal(true);
+            }}
+            className="inline-block cursor-pointer"
+            title="Click to update status"
+          >
+            <NodeStatusChip status={node.status} size="sm">
+              {node.statusLabel}
+            </NodeStatusChip>
+          </div>
         </TableCell>
 
         {/* Error / Logs */}
@@ -485,6 +691,7 @@ function NodeStatusTableRow({
                         onClick={
                           node.stderr
                             ? (e) => {
+                              e.stopPropagation();
                               if (!(e.metaKey || e.ctrlKey) && onViewLog) {
                                 e.preventDefault();
                                 onViewLog(
@@ -493,7 +700,10 @@ function NodeStatusTableRow({
                                 );
                               }
                             }
-                            : handleViewLog
+                            : (e) => {
+                              e.stopPropagation();
+                              handleViewLog(e);
+                            }
                         }
                         className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium transition-colors duration-200 rounded cursor-pointer text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700"
                         title={`Click to view ${node.stderr ? 'stderr' : 'stdout'} log (Cmd/Ctrl+Click to open in new tab)`}
@@ -515,7 +725,10 @@ function NodeStatusTableRow({
                       <TooltipTrigger asChild>
                         <a
                           href={url}
-                          onClick={handleViewLog}
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            handleViewLog(e);
+                          }}
                           className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium transition-colors duration-200 text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer border-r border-slate-200 dark:border-slate-700"
                           title="Click to view stdout log (Cmd/Ctrl+Click to open in new tab)"
                         >
@@ -532,6 +745,7 @@ function NodeStatusTableRow({
                         <a
                           href={`${url}&stream=stderr`}
                           onClick={(e) => {
+                            e.stopPropagation();
                             if (!(e.metaKey || e.ctrlKey) && onViewLog) {
                               e.preventDefault();
                               onViewLog(
@@ -574,7 +788,10 @@ function NodeStatusTableRow({
             <button
               className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700"
               title="Retry from this step"
-              onClick={() => setShowDialog(true)}
+              onClick={(e) => {
+                e.stopPropagation();
+                setShowDialog(true);
+              }}
               disabled={loading}
             >
               <PlayCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
@@ -612,6 +829,93 @@ function NodeStatusTableRow({
           </TableCell>
         )}
       </StyledTableRow>
+
+      {/* Inline log viewer row - spans entire table width */}
+      {isLogExpanded && hasLogs && (
+        <StyledTableRow className="bg-zinc-50 dark:bg-zinc-900">
+          <TableCell colSpan={dagRunId ? 7 : 6} className="p-3">
+            <div className="w-full">
+              {/* Header with tabs and expand button */}
+              <div className="flex items-center justify-between mb-2">
+                {/* Simple tabs for out/err */}
+                {hasStdout && hasStderr ? (
+                  <div className="flex items-center gap-1">
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        setActiveLogTab('stdout');
+                      }}
+                      className={cn(
+                        "px-3 py-1 text-xs font-medium transition-colors rounded",
+                        activeLogTab === 'stdout'
+                          ? "bg-slate-700 dark:bg-slate-600 text-white"
+                          : "bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600"
+                      )}
+                    >
+                      out
+                    </button>
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        setActiveLogTab('stderr');
+                      }}
+                      className={cn(
+                        "px-3 py-1 text-xs font-medium transition-colors rounded",
+                        activeLogTab === 'stderr'
+                          ? "bg-slate-700 dark:bg-slate-600 text-white"
+                          : "bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600"
+                      )}
+                    >
+                      err
+                    </button>
+                  </div>
+                ) : (
+                  <div className="text-xs font-medium text-slate-600 dark:text-slate-400">
+                    {hasStdout ? 'stdout' : 'stderr'}
+                  </div>
+                )}
+
+                {/* Expand to modal button */}
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    if (onViewLog) {
+                      onViewLog(
+                        currentStream === 'stderr' ? `${node.step.name}_stderr` : node.step.name,
+                        dagRunId || '',
+                        node
+                      );
+                    }
+                  }}
+                  className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
+                  title="Open in full modal"
+                >
+                  <Code className="h-3 w-3" />
+                  <span>Full view</span>
+                </button>
+              </div>
+
+              {/* Simple inline log viewer - no controls */}
+              <InlineLogViewer
+                dagName={dagRun?.name || name}
+                dagRunId={dagRunId || ''}
+                stepName={node.step.name}
+                stream={currentStream}
+                dagRun={dagRun}
+              />
+            </div>
+          </TableCell>
+        </StyledTableRow>
+      )}
+
+      {/* Status Update Modal */}
+      <StatusUpdateModal
+        visible={showStatusModal}
+        dismissModal={() => setShowStatusModal(false)}
+        step={node.step}
+        onSubmit={handleStatusUpdate}
+      />
+      </>
     );
   }
 
diff --git a/ui/src/features/dags/components/dag-editor/DAGSpec.tsx b/ui/src/features/dags/components/dag-editor/DAGSpec.tsx
index 7bd16ab2..0f819d36 100644
--- a/ui/src/features/dags/components/dag-editor/DAGSpec.tsx
+++ b/ui/src/features/dags/components/dag-editor/DAGSpec.tsx
@@ -53,6 +53,10 @@ function DAGSpec({ fileName }: Props) {
   const containerRef = React.useRef<HTMLDivElement>(null);
   const lastFetchedSpecRef = React.useRef<string | undefined>(undefined);
 
+  // Reference to save function and refresh callback for keyboard shortcut
+  const saveHandlerRef = React.useRef<(() => Promise<void>) | null>(null);
+  const refreshCallbackRef = React.useRef<(() => void) | null>(null);
+
   /**
    * Handle flowchart direction change and save preference to cookie
    */
@@ -124,6 +128,47 @@ function DAGSpec({ fileName }: Props) {
     }
   }, []);
 
+  // Save handler function
+  const handleSave = React.useCallback(async () => {
+    if (!currentValue) {
+      alert('No changes to save');
+      return;
+    }
+
+    // Save current scroll position before any operations that might cause re-render
+    saveScrollPosition();
+
+    const { data: responseData, error } = await client.PUT(
+      '/dags/{fileName}/spec',
+      {
+        params: {
+          path: {
+            fileName: fileName,
+          },
+          query: {
+            remoteNode: appBarContext.selectedRemoteNode || 'local',
+          },
+        },
+        body: {
+          spec: currentValue,
+        },
+      }
+    );
+
+    if (error) {
+      alert(error.message || 'Failed to save spec');
+      return;
+    }
+
+    if (responseData?.errors) {
+      alert(responseData.errors.join('\n'));
+      return;
+    }
+
+    // Show success toast notification
+    showToast('Changes saved successfully');
+  }, [currentValue, fileName, appBarContext.selectedRemoteNode, client, saveScrollPosition, showToast]);
+
   // Restore scroll position after render
   useEffect(() => {
     if (scrollPosition > 0) {
@@ -139,6 +184,43 @@ function DAGSpec({ fileName }: Props) {
     }
   }, [scrollPosition]);
 
+  // Update save handler ref when handleSave changes
+  useEffect(() => {
+    saveHandlerRef.current = handleSave;
+  }, [handleSave]);
+
+  // Add keyboard shortcut for saving (Ctrl+S / Cmd+S)
+  useEffect(() => {
+    if (!editable) {
+      return;
+    }
+
+    const handleKeyDown = async (event: KeyboardEvent) => {
+      // Check for Ctrl+S (Windows/Linux) or Cmd+S (macOS)
+      if ((event.ctrlKey || event.metaKey) && event.key === 's') {
+        event.preventDefault(); // Prevent browser's default save dialog
+
+        // Call the save handler if available
+        if (saveHandlerRef.current) {
+          await saveHandlerRef.current();
+
+          // Refresh after saving
+          if (refreshCallbackRef.current) {
+            refreshCallbackRef.current();
+          }
+        }
+      }
+    };
+
+    // Add event listener to document
+    document.addEventListener('keydown', handleKeyDown);
+
+    // Cleanup on unmount
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [editable]);
+
   // Show loading indicator while fetching data
   if (isLoading) {
     return <LoadingIndicator />;
@@ -260,8 +342,11 @@ function DAGSpec({ fileName }: Props) {
 
   return (
     <DAGContext.Consumer>
-      {(props) =>
-        data?.dag && (
+      {(props) => {
+        // Update refresh callback ref directly (safe in render)
+        refreshCallbackRef.current = props.refresh;
+
+        return data?.dag && (
           <React.Fragment>
             <div className="space-y-6" ref={containerRef}>
               {hasLocalDags ? (
@@ -340,47 +425,18 @@ function DAGSpec({ fileName }: Props) {
                         id="save-config"
                         variant="default"
                         size="sm"
-                        className="cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200"
+                        title="Save changes (Ctrl+S / Cmd+S)"
+                        className="cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200 relative group"
                         onClick={async () => {
-                          if (!currentValue) {
-                            alert('No changes to save');
-                            return;
-                          }
-                          // Save current scroll position before any operations that might cause re-render
-                          saveScrollPosition();
-                          const { data, error } = await client.PUT(
-                            '/dags/{fileName}/spec',
-                            {
-                              params: {
-                                path: {
-                                  fileName: props.fileName,
-                                },
-                                query: {
-                                  remoteNode:
-                                    appBarContext.selectedRemoteNode || 'local',
-                                },
-                              },
-                              body: {
-                                spec: currentValue,
-                              },
-                            }
-                          );
-                          if (error) {
-                            alert(error.message || 'Failed to save spec');
-                            return;
-                          }
-                          if (data?.errors) {
-                            alert(data.errors.join('\n'));
-                            return;
-                          }
-                          // Show success toast notification
-                          showToast('Changes saved successfully');
-
+                          await handleSave();
                           props.refresh();
                         }}
                       >
                         <Save className="h-4 w-4 mr-1" />
                         Save Changes
+                        <span className="absolute -bottom-1 -right-1 bg-primary-foreground text-primary text-[10px] font-medium px-1 rounded-sm opacity-0 group-hover:opacity-100 transition-opacity">
+                          {navigator.platform.indexOf('Mac') > -1 ? '⌘S' : 'Ctrl+S'}
+                        </span>
                       </Button>
                     </div>
                   ) : null}
@@ -403,8 +459,8 @@ function DAGSpec({ fileName }: Props) {
               </div>
             </div>
           </React.Fragment>
-        )
-      }
+        );
+      }}
     </DAGContext.Consumer>
   );
 }
diff --git a/ui/src/features/dags/components/dag-list/DAGTable.tsx b/ui/src/features/dags/components/dag-list/DAGTable.tsx
index bcfc9b16..84bfa326 100644
--- a/ui/src/features/dags/components/dag-list/DAGTable.tsx
+++ b/ui/src/features/dags/components/dag-list/DAGTable.tsx
@@ -322,7 +322,6 @@ const defaultColumns = [
         const searchValue = String(filterValue).toLowerCase();
 
         // Search in name and description
-        console.log({ data });
         if (
           fileName.includes(searchValue) ||
           name.includes(searchValue) ||
@@ -1254,7 +1253,10 @@ function DAGTable({
                         {groupRow.name}
                       </span>
                     </div>
-                    <Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 flex-shrink-0">
+                    <Badge
+                      variant="secondary"
+                      className="text-[10px] px-1.5 py-0 h-4 flex-shrink-0"
+                    >
                       {row.subRows?.length || 0}
                     </Badge>
                   </div>
@@ -1263,7 +1265,10 @@ function DAGTable({
                   {isExpanded && row.subRows && row.subRows.length > 0 && (
                     <div className="space-y-1.5 pl-2 border-l-2 border-muted-foreground/20 ml-3">
                       {row.subRows.map((subRow) => {
-                        if (subRow.original?.kind === ItemKind.DAG && 'dag' in subRow.original) {
+                        if (
+                          subRow.original?.kind === ItemKind.DAG &&
+                          'dag' in subRow.original
+                        ) {
                           const dagRow = subRow.original as DAGRow;
                           const dag = dagRow.dag;
                           const fileName = dag.fileName;
@@ -1290,7 +1295,9 @@ function DAGTable({
                             >
                               {/* Compact header */}
                               <div className="flex justify-between items-start gap-2 mb-1.5">
-                                <div className="font-medium text-xs truncate flex-1 min-w-0">{dag.dag.name}</div>
+                                <div className="font-medium text-xs truncate flex-1 min-w-0">
+                                  {dag.dag.name}
+                                </div>
                                 <StatusChip status={status} size="xs">
                                   {statusLabel}
                                 </StatusChip>
@@ -1305,23 +1312,26 @@ function DAGTable({
 
                               {/* Schedule & Last Run - inline */}
                               <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground mb-1.5">
-                                {dag.dag.schedule && dag.dag.schedule.length > 0 ? (
-                                  dag.dag.schedule.map((schedule, idx) => (
-                                    <Badge
-                                      key={idx}
-                                      variant="outline"
-                                      className="text-[9px] font-normal px-1 py-0 h-3"
-                                    >
-                                      {schedule.expression}
-                                    </Badge>
-                                  ))
-                                ) : null}
-                                {dag.latestDAGRun.startedAt && dag.latestDAGRun.startedAt !== '-' && (
-                                  <span className="flex items-center gap-0.5">
-                                    <Calendar className="h-2.5 w-2.5" />
-                                    <span className="text-[9px]">{dag.latestDAGRun.startedAt}</span>
-                                  </span>
-                                )}
+                                {dag.dag.schedule && dag.dag.schedule.length > 0
+                                  ? dag.dag.schedule.map((schedule, idx) => (
+                                      <Badge
+                                        key={idx}
+                                        variant="outline"
+                                        className="text-[9px] font-normal px-1 py-0 h-3"
+                                      >
+                                        {schedule.expression}
+                                      </Badge>
+                                    ))
+                                  : null}
+                                {dag.latestDAGRun.startedAt &&
+                                  dag.latestDAGRun.startedAt !== '-' && (
+                                    <span className="flex items-center gap-0.5">
+                                      <Calendar className="h-2.5 w-2.5" />
+                                      <span className="text-[9px]">
+                                        {dag.latestDAGRun.startedAt}
+                                      </span>
+                                    </span>
+                                  )}
                               </div>
 
                               {/* Tags - compact */}
@@ -1356,7 +1366,11 @@ function DAGTable({
 
             // Render standalone DAG rows (not in a group)
             // Skip if this row has a parent (it's already rendered within a group)
-            if (row.original?.kind === ItemKind.DAG && 'dag' in row.original && row.depth === 0) {
+            if (
+              row.original?.kind === ItemKind.DAG &&
+              'dag' in row.original &&
+              row.depth === 0
+            ) {
               const dagRow = row.original as DAGRow;
               const dag = dagRow.dag;
               const fileName = dag.fileName;
@@ -1383,7 +1397,9 @@ function DAGTable({
                 >
                   {/* Compact header */}
                   <div className="flex justify-between items-start gap-2 mb-1.5">
-                    <div className="font-medium text-xs truncate flex-1 min-w-0">{dag.dag.name}</div>
+                    <div className="font-medium text-xs truncate flex-1 min-w-0">
+                      {dag.dag.name}
+                    </div>
                     <StatusChip status={status} size="xs">
                       {statusLabel}
                     </StatusChip>
@@ -1398,23 +1414,26 @@ function DAGTable({
 
                   {/* Schedule & Last Run - inline */}
                   <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground mb-1.5">
-                    {dag.dag.schedule && dag.dag.schedule.length > 0 ? (
-                      dag.dag.schedule.map((schedule, idx) => (
-                        <Badge
-                          key={idx}
-                          variant="outline"
-                          className="text-[9px] font-normal px-1 py-0 h-3"
-                        >
-                          {schedule.expression}
-                        </Badge>
-                      ))
-                    ) : null}
-                    {dag.latestDAGRun.startedAt && dag.latestDAGRun.startedAt !== '-' && (
-                      <span className="flex items-center gap-0.5">
-                        <Calendar className="h-2.5 w-2.5" />
-                        <span className="text-[9px]">{dag.latestDAGRun.startedAt}</span>
-                      </span>
-                    )}
+                    {dag.dag.schedule && dag.dag.schedule.length > 0
+                      ? dag.dag.schedule.map((schedule, idx) => (
+                          <Badge
+                            key={idx}
+                            variant="outline"
+                            className="text-[9px] font-normal px-1 py-0 h-3"
+                          >
+                            {schedule.expression}
+                          </Badge>
+                        ))
+                      : null}
+                    {dag.latestDAGRun.startedAt &&
+                      dag.latestDAGRun.startedAt !== '-' && (
+                        <span className="flex items-center gap-0.5">
+                          <Calendar className="h-2.5 w-2.5" />
+                          <span className="text-[9px]">
+                            {dag.latestDAGRun.startedAt}
+                          </span>
+                        </span>
+                      )}
                   </div>
 
                   {/* Tags - compact */}
@@ -1445,9 +1464,7 @@ function DAGTable({
         ) : (
           <div className="flex flex-col items-center justify-center py-12 px-4 border rounded-md bg-card">
             <div className="text-6xl mb-4">🔍</div>
-            <h3 className="text-lg font-medium mb-2">
-              No DAGs found
-            </h3>
+            <h3 className="text-lg font-medium mb-2">No DAGs found</h3>
             <p className="text-sm text-muted-foreground text-center max-w-md mb-4">
               There are no DAGs matching your current filters. Try adjusting
               your search criteria or tags.
diff --git a/ui/src/features/dags/components/visualization/Graph.tsx b/ui/src/features/dags/components/visualization/Graph.tsx
index ada11df7..cc0a12f7 100644
--- a/ui/src/features/dags/components/visualization/Graph.tsx
+++ b/ui/src/features/dags/components/visualization/Graph.tsx
@@ -122,7 +122,7 @@ const Graph: React.FC<Props> = ({
 
     // Add legend comment
     dat.push(
-      `%% Shapes: Rectangle=Normal Step, Hexagon=Child DAG, Stadium=Parallel Execution`
+      `%% Shapes: Rectangle=Normal Step, Subprocess=Single Child DAG, Processes=Parallel Execution`
     );
 
     // Store the click handler in window for backward compatibility
@@ -134,6 +134,8 @@ const Graph: React.FC<Props> = ({
     // Track link style indices for individual arrow styling
     let linkIndex = 0;
     const linkStyles: string[] = [];
+    // Track node classes for separate application
+    const nodeClasses = new Map<string, string>();
 
     const addNodeFn = (
       step: components['schemas']['Step'],
@@ -145,7 +147,7 @@ const Graph: React.FC<Props> = ({
 
       // Check if this is a child dagRun node (has a 'run' property)
       const isChildDAGRun = !!step.run;
-      const hasParallelExecutions = node?.children && node.children.length > 1;
+      const hasParallelExecutions = !!step.parallel;
 
       // Add indicator for child dagRun nodes in the label only
       // Escape any special characters in the label to prevent Mermaid parsing errors
@@ -160,10 +162,21 @@ const Graph: React.FC<Props> = ({
         }
       }
 
-      // Use different shape for child dagRuns
+      // Use different shapes based on node type
       if (isChildDAGRun) {
-        dat.push(`${id}{{${label}}}${c};`);
+        if (hasParallelExecutions) {
+          // Multiple parallel executions - use procs icon
+          dat.push(`${id}@{ shape: procs, label: "${label}"};`);
+        } else {
+          // Single child DAG - use subproc icon
+          dat.push(`${id}@{ shape: subproc, label: "${label}"};`);
+        }
+        // Store class for later application (remove ::: prefix)
+        if (c) {
+          nodeClasses.set(id, c.replace(':::', ''));
+        }
       } else {
+        // Normal step - use rectangle with inline class syntax
         dat.push(`${id}["${label}"]${c};`);
       }
 
@@ -240,6 +253,11 @@ const Graph: React.FC<Props> = ({
     // Add custom link styles
     dat.push(...linkStyles);
 
+    // Apply classes to nodes that use the new shape syntax (procs/subproc)
+    nodeClasses.forEach((className, nodeId) => {
+      dat.push(`class ${nodeId} ${className};`);
+    });
+
     return dat.join('\n');
   }, [steps, onClickNode, flowchart, showIcons]);
 



Test patch:
diff --git a/internal/integration/container_test.go b/internal/integration/container_test.go
index d1392f58..893f18e2 100644
--- a/internal/integration/container_test.go
+++ b/internal/integration/container_test.go
@@ -32,8 +32,6 @@ type dockerExecutorTest struct {
 }
 
 func TestDockerExecutor(t *testing.T) {
-	t.Parallel()
-
 	tests := []dockerExecutorTest{
 		{
 			name: "BasicExecution",
diff --git a/internal/integration/retry_test.go b/internal/integration/retry_test.go
new file mode 100644
index 00000000..663d956f
--- /dev/null
+++ b/internal/integration/retry_test.go
@@ -0,0 +1,101 @@
+package integration_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/dagu-org/dagu/internal/cmd"
+	"github.com/dagu-org/dagu/internal/core"
+	"github.com/dagu-org/dagu/internal/core/execution"
+	"github.com/dagu-org/dagu/internal/test"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+)
+
+// TestRetryDAGAfterManualStatusUpdate reproduces a bug where retrying a DAG
+// after manually updating a node status to failure does not properly retry
+// the failed node. The expected behavior is that the retry should re-execute
+// the failed node, but the bug causes it to skip or fail incorrectly.
+func TestRetryDAGAfterManualStatusUpdate(t *testing.T) {
+	th := test.SetupCommand(t)
+
+	// Create a simple 3-step DAG where all steps should succeed
+	th.CreateDAGFile(t, "test_retry.yaml", `steps:
+  - name: step1
+    command: echo "step 1"
+    output: OUT1
+
+  - name: step2
+    command: echo "step 2"
+    output: OUT2
+    depends:
+      - step1
+
+  - name: step3
+    command: echo "step 3"
+    output: OUT3
+    depends:
+      - step2
+`)
+
+	// First run - should succeed completely
+	dagRunID := uuid.Must(uuid.NewV7()).String()
+	args := []string{"start", "--run-id", dagRunID, "test_retry"}
+	th.RunCommand(t, cmd.Start(), test.CmdTest{
+		Args:        args,
+		ExpectedOut: []string{"dag-run finished"},
+	})
+
+	// Verify the initial run completed successfully
+	ctx := context.Background()
+	ref := execution.NewDAGRunRef("test_retry", dagRunID)
+	attempt, err := th.DAGRunStore.FindAttempt(ctx, ref)
+	require.NoError(t, err)
+
+	firstRunStatus, err := attempt.ReadStatus(ctx)
+	require.NoError(t, err)
+	require.Equal(t, firstRunStatus.Status, core.Succeeded)
+
+	// Manually update the second node status to failed
+	// This simulates a scenario where the user wants to retry from a specific point
+	firstRunStatus.Nodes[1].Status = core.NodeFailed
+
+	err = th.DAGRunMgr.UpdateStatus(ctx, ref, *firstRunStatus)
+	require.NoError(t, err)
+
+	// Read back the status to verify it was persisted correctly
+	readStatus, err := attempt.ReadStatus(ctx)
+	require.NoError(t, err)
+	require.Equal(t, core.NodeFailed.String(), readStatus.Nodes[1].Status.String(), "step2 should be marked as failed in persisted status")
+
+	// Now retry the DAG using the retry command
+	retryArgs := []string{"retry", "--run-id", dagRunID, "test_retry"}
+	th.RunCommand(t, cmd.Retry(), test.CmdTest{
+		Args:        retryArgs,
+		ExpectedOut: []string{"dag-run finished"},
+	})
+
+	// The bug: The retry does not succeed even though all commands are valid
+	// Expected: The retry should re-execute step2 and step3 successfully
+	// Actual: The retry fails or doesn't properly execute the failed nodes
+
+	// Get the status after retry
+	retryAttempt, err := th.DAGRunStore.FindAttempt(ctx, ref)
+	require.NoError(t, err)
+	retryStatus, err := retryAttempt.ReadStatus(ctx)
+	require.NoError(t, err)
+
+	// This assertion will fail if the bug exists
+	// The retry should have succeeded since all commands are valid
+	t.Logf("Retry status: %s", retryStatus.Status.String())
+	for i, node := range retryStatus.Nodes {
+		t.Logf("Node %d (%s): status=%s, error=%s", i, node.Step.Name, node.Status.String(), node.Error)
+	}
+
+	// The expected behavior is that the retry succeeds
+	require.Equal(t, core.Succeeded.String(), retryStatus.Status.String(), "retry should succeed")
+
+	// Verify that step2 and step3 were re-executed
+	require.Equal(t, core.NodeSucceeded.String(), retryStatus.Nodes[1].Status.String(), "step2 should have succeeded after retry")
+	require.Equal(t, core.NodeSucceeded.String(), retryStatus.Nodes[2].Status.String(), "step3 should have succeeded after retry")
+}