Fix real-time build log streaming and scroll behavior
- Use line-by-line reading for Docker build output instead of io.Copy to ensure each log line is written immediately without buffering - Add isNearBottom() helper to check scroll position before auto-scroll - Only auto-scroll logs if user was already near bottom (better UX) - Use requestAnimationFrame for smoother scroll-to-bottom animation
This commit is contained in:
parent
f1cc7d65a6
commit
d2f2747ae6
@ -2,6 +2,7 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -489,15 +490,10 @@ func (c *Client) performBuild(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Read build output - write to stdout and optional log writer
|
// Stream build output line by line for real-time log updates
|
||||||
var output io.Writer = os.Stdout
|
err = c.streamBuildOutput(resp.Body, opts.LogWriter)
|
||||||
if opts.LogWriter != nil {
|
|
||||||
output = io.MultiWriter(os.Stdout, opts.LogWriter)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(output, resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read build output: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get image ID
|
// Get image ID
|
||||||
@ -513,6 +509,41 @@ func (c *Client) performBuild(
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
|
||||||
|
const scannerInitialBufferSize = 64 * 1024 // 64KB
|
||||||
|
|
||||||
|
// scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large).
|
||||||
|
const scannerMaxBufferSize = 1024 * 1024 // 1MB
|
||||||
|
|
||||||
|
// streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer.
|
||||||
|
// Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately.
|
||||||
|
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
||||||
|
scanner := bufio.NewScanner(body)
|
||||||
|
buf := make([]byte, 0, scannerInitialBufferSize)
|
||||||
|
scanner.Buffer(buf, scannerMaxBufferSize)
|
||||||
|
|
||||||
|
newline := []byte{'\n'}
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
// Write to stdout
|
||||||
|
_, _ = os.Stdout.Write(line)
|
||||||
|
_, _ = os.Stdout.Write(newline)
|
||||||
|
// Write to log writer if provided
|
||||||
|
if logWriter != nil {
|
||||||
|
_, _ = logWriter.Write(line)
|
||||||
|
_, _ = logWriter.Write(newline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanErr := scanner.Err()
|
||||||
|
if scanErr != nil {
|
||||||
|
return fmt.Errorf("failed to read build output: %w", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
|
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
|
||||||
// Create work directory for clone destination
|
// Create work directory for clone destination
|
||||||
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
||||||
|
|||||||
@ -59,12 +59,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
return status === "building" || status === "deploying";
|
return status === "building" || status === "deploying";
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element is scrolled near the bottom (within threshold)
|
||||||
|
*/
|
||||||
|
isNearBottom(el, threshold = 100) {
|
||||||
|
if (!el) return true;
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll an element to the bottom
|
* Scroll an element to the bottom
|
||||||
*/
|
*/
|
||||||
scrollToBottom(el) {
|
scrollToBottom(el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
|
// Use requestAnimationFrame for smoother scrolling after DOM update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -210,14 +221,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
async fetchContainerLogs() {
|
async fetchContainerLogs() {
|
||||||
try {
|
try {
|
||||||
|
const wrapper = this.$refs.containerLogsWrapper;
|
||||||
|
const wasNearBottom =
|
||||||
|
Alpine.store("utils").isNearBottom(wrapper);
|
||||||
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.containerLogs = data.logs || "No logs available";
|
this.containerLogs = data.logs || "No logs available";
|
||||||
this.containerStatus = data.status;
|
this.containerStatus = data.status;
|
||||||
|
if (wasNearBottom) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const wrapper = this.$refs.containerLogsWrapper;
|
Alpine.store("utils").scrollToBottom(wrapper);
|
||||||
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.containerLogs = "Failed to fetch logs";
|
this.containerLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -226,16 +241,20 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async fetchBuildLogs() {
|
async fetchBuildLogs() {
|
||||||
if (!this.currentDeploymentId) return;
|
if (!this.currentDeploymentId) return;
|
||||||
try {
|
try {
|
||||||
|
const wrapper = this.$refs.buildLogsWrapper;
|
||||||
|
const wasNearBottom =
|
||||||
|
Alpine.store("utils").isNearBottom(wrapper);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.buildLogs = data.logs || "No build logs available";
|
this.buildLogs = data.logs || "No build logs available";
|
||||||
this.buildStatus = data.status;
|
this.buildStatus = data.status;
|
||||||
|
if (wasNearBottom) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const wrapper = this.$refs.buildLogsWrapper;
|
Alpine.store("utils").scrollToBottom(wrapper);
|
||||||
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.buildLogs = "Failed to fetch logs";
|
this.buildLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -364,16 +383,22 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async fetchLiveLogs() {
|
async fetchLiveLogs() {
|
||||||
if (!this.currentDeploymentId || !this.isDeploying) return;
|
if (!this.currentDeploymentId || !this.isDeploying) return;
|
||||||
try {
|
try {
|
||||||
|
const wrapper = this.$refs.liveLogsWrapper;
|
||||||
|
const wasNearBottom =
|
||||||
|
Alpine.store("utils").isNearBottom(wrapper);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.liveLogs = data.logs || "Waiting for logs...";
|
this.liveLogs = data.logs || "Waiting for logs...";
|
||||||
this.liveStatus = data.status;
|
this.liveStatus = data.status;
|
||||||
|
|
||||||
|
if (wasNearBottom) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const wrapper = this.$refs.liveLogsWrapper;
|
Alpine.store("utils").scrollToBottom(wrapper);
|
||||||
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update matching deployment card if present
|
// Update matching deployment card if present
|
||||||
const card = document.querySelector(
|
const card = document.querySelector(
|
||||||
@ -382,11 +407,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (card) {
|
if (card) {
|
||||||
const logsContent = card.querySelector(".logs-content");
|
const logsContent = card.querySelector(".logs-content");
|
||||||
const logsWrapper = card.querySelector(".logs-wrapper");
|
const logsWrapper = card.querySelector(".logs-wrapper");
|
||||||
|
const cardWasNearBottom =
|
||||||
|
Alpine.store("utils").isNearBottom(logsWrapper);
|
||||||
const statusBadge =
|
const statusBadge =
|
||||||
card.querySelector(".deployment-status");
|
card.querySelector(".deployment-status");
|
||||||
if (logsContent)
|
if (logsContent)
|
||||||
logsContent.textContent = data.logs || "Loading...";
|
logsContent.textContent = data.logs || "Loading...";
|
||||||
if (logsWrapper)
|
if (logsWrapper && cardWasNearBottom)
|
||||||
Alpine.store("utils").scrollToBottom(logsWrapper);
|
Alpine.store("utils").scrollToBottom(logsWrapper);
|
||||||
if (statusBadge) {
|
if (statusBadge) {
|
||||||
statusBadge.className =
|
statusBadge.className =
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user