Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions ui/config/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ image: <%= ENV["KAMAL_APP_NAME_UI"] %>
# Deploy to these servers.
servers:
web:
hosts:
hosts:
- <%= ENV["KAMAL_SERVER_IP"] %>
# job:
# hosts:
Expand All @@ -19,12 +19,21 @@ servers:
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.
#
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
proxy:
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
proxy:
ssl: true
host: <%= ENV["KAMAL_APP_DOMAIN"] %>
# Proxy connects to your container on port 80 by default.
app_port: 3000
# The Zenodo release streams progress over a long-lived SSE connection that
# can run for several minutes on releases with many or large assets. Without
# these two settings kamal-proxy (a) buffers the whole response - which breaks
# incremental progress events and nullifies the 15s heartbeat - and (b) cuts
# the request off at its 30s default `response_timeout`, so a release that
# completes on the server shows up as a failure in the browser.
response_timeout: 600
buffering:
responses: false

# Credentials for your image host.
registry:
Expand Down
109 changes: 103 additions & 6 deletions ui/pages/dashboard/[owner]/[repo]/release/zenodo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,62 @@ const navigateToDashboard = async () => {
await navigateTo(`/dashboard/${owner}/${repo}/`);
};

const reconcilePublishStatus = async () => {
// The SSE stream ended without a terminal event - the proxy/browser likely
// dropped the long-lived connection. The backend may still be finishing, so
// poll the authoritative DB status a few times before deciding the outcome.
const maxAttempts = 5;
const delayMs = 3000;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const status = await $fetch<{
zenodoDoi: string;
zenodoWorkflowStatus: string;
}>(`/api/${owner}/${repo}/release/zenodo/status`, {
credentials: "include",
});

if (status.zenodoWorkflowStatus === "published") {
// Release actually succeeded
zenodoPublishStatus.value = "published";
if (status.zenodoDoi) {
zenodoPublishDOI.value = status.zenodoDoi;
}
for (const step of publishSteps.value) {
if (step.status !== "error") {
step.status = "completed";
}
}
resetForm();
return;
}

if (status.zenodoWorkflowStatus === "error") {
zenodoPublishStatus.value = "error";
zenodoPublishErrorMessage.value =
"The release failed on the server. Please review your settings and try again.";
const inProgress = publishSteps.value.find(
(s) => s.status === "in_progress",
);
if (inProgress) {
inProgress.status = "error";
}
return;
}
} catch {
// retry on the next attempt
}

if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

zenodoPublishStatus.value = "pending";
zenodoPublishErrorMessage.value = "";
};

const startZenodoPublishProcess = async (shouldPublish: boolean = false) => {
if (shouldPublish) {
zenodoPublishSpinner.value = true;
Expand All @@ -620,6 +676,10 @@ const startZenodoPublishProcess = async (shouldPublish: boolean = false) => {

zenodoPreflightError.value = null;

// Track whether the SSE stream delivered a terminal (complete/error) event.
let streamingStarted = false;
let receivedTerminal = false;

try {
const response = await fetch(`/api/${owner}/${repo}/release/zenodo`, {
body,
Expand Down Expand Up @@ -692,6 +752,7 @@ const startZenodoPublishProcess = async (shouldPublish: boolean = false) => {
showZenodoPublishProgressModal.value = true;
zenodoPublishStatus.value = "inProgress";
resetPublishSteps();
streamingStarted = true;

const reader = response.body!.getReader();
const decoder = new TextDecoder();
Expand All @@ -710,6 +771,9 @@ const startZenodoPublishProcess = async (shouldPublish: boolean = false) => {
try {
const event = JSON.parse(line.slice(6));
handleSSEEvent(event);
if (event.step === "complete" || event.step === "error") {
receivedTerminal = true;
}
} catch {
// ignore malformed lines
}
Expand All @@ -718,14 +782,19 @@ const startZenodoPublishProcess = async (shouldPublish: boolean = false) => {
}
} catch (error) {
console.error("Failed to start Zenodo publish process:", error);
push.error({
title: "Failed to start Zenodo publish process",
message: "Please try again later",
});
zenodoPublishStatus.value = "error";
if (!streamingStarted) {
push.error({
title: "Failed to start Zenodo publish process",
message: "Please try again later",
});
zenodoPublishStatus.value = "error";
}
} finally {
zenodoPublishSpinner.value = false;
zenodoDraftSpinner.value = false;
if (shouldPublish && streamingStarted && !receivedTerminal) {
await reconcilePublishStatus();
}
fetchZenodoBadge();
}
};
Expand Down Expand Up @@ -1848,6 +1917,12 @@ onBeforeUnmount(() => {
class="h-6 w-6 text-blue-600"
/>

<Icon
v-else-if="zenodoPublishStatus === 'pending'"
name="mdi:clock-alert-outline"
class="h-6 w-6 text-amber-500"
/>

<!-- Dynamic title -->
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100">
{{
Expand All @@ -1857,7 +1932,9 @@ onBeforeUnmount(() => {
? "Zenodo publish error"
: zenodoPublishStatus === "published"
? "Zenodo publish success"
: "Loading..."
: zenodoPublishStatus === "pending"
? "Zenodo publish still processing"
: "Loading..."
}}
</h2>
</div>
Expand Down Expand Up @@ -2069,6 +2146,20 @@ onBeforeUnmount(() => {
</div>
</n-flex>

<!-- Pending: reconciliation timed out while the server is still working -->
<n-flex
v-else-if="zenodoPublishStatus === 'pending'"
vertical
class="space-y-3"
>
<p class="text-gray-700 dark:text-gray-300">
Your release is still finishing on the server. Large releases can take
a few minutes to upload and archive. You can safely leave this page —
check your repository dashboard shortly; the release may still
complete successfully.
</p>
</n-flex>

<!-- Loading state -->
<n-flex v-else>
<p>Please wait while we get the status of your workflow.</p>
Expand Down Expand Up @@ -2113,6 +2204,12 @@ onBeforeUnmount(() => {
Return to Dashboard
</n-button>
</n-flex>

<n-flex v-else-if="zenodoPublishStatus === 'pending'" justify="center">
<n-button type="primary" size="small" @click="navigateToDashboard">
Return to Dashboard
</n-button>
</n-flex>
</template>
</n-modal>
</main>
Expand Down
11 changes: 11 additions & 0 deletions ui/server/api/[owner]/[repo]/release/zenodo/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ export default defineEventHandler(async (event) => {
}
};

// Keep the connection alive during long steps. Without traffic, an idle reverse proxy / load
// balancer / browser can silently drop the stream, making a successful
// release look failed to the client.
const heartbeat = setInterval(() => {
if (clientConnected && !res.writableEnded) {
res.write(": ping\n\n");
}
}, 15000);

/**
* Forwards a publication progress event to the SSE stream.
* @param progress - Progress event from the Zenodo publication workflow.
Expand Down Expand Up @@ -294,6 +303,8 @@ export default defineEventHandler(async (event) => {
status: "error",
step: "error",
});
} finally {
clearInterval(heartbeat);
}

if (!res.writableEnded) {
Expand Down
Loading