hawkBit deployment feedback currently only reports coarse proceeding/finished
state and log details. This makes long-running downloads or installations hard
to observe from hawkBit.
This patch subscribes to SWUpdate progress IPC from the hawkBit notification
thread and emits a compact, machine-readable progress detail string into the
existing hawkBit feedback details path.
Progress details are coalesced and share the existing feedback path. A global
feedback throttle limits all feedback POSTs, including both normal log batches
and progress snapshots, so progress reporting cannot increase feedback
frequency beyond the configured interval, except for important state changes.
Normal PROGRESS status messages are filtered from details[] to avoid pushing
unstructured progress noise into hawkBit action logs.
The emitted detail format is intended to be consumed by a corresponding
hawkBit UI change, which parses the progress detail entries and renders target
download/install progress. Without the UI change, the patch still reports
progress as ordinary hawkBit action-log details, but hawkBit will not display it
as a dedicated progress indicator.
diff --git a/suricatta/server_hawkbit.c b/suricatta/server_hawkbit.c
index a9d09ac5..57c4816c 100644
--- a/suricatta/server_hawkbit.c
+++ b/suricatta/server_hawkbit.c
@@ -20,6 +20,7 @@
#include <sys/time.h>
#include <swupdate_status.h>
#include <pthread.h>
+#include <progress_ipc.h>
#include "suricatta/suricatta.h"
#include "suricatta/server.h"
#include "server_utils.h"
@@ -987,6 +988,54 @@ static int server_update_status_callback(ipc_message *msg)
return 0;
}
+/*
+ * These functions are for V2 of the protocol
+ */
+#define enum_string(x) [x] = #x
+static const char *get_status_string(unsigned int status)
+{
+ const char * const str[] = {
+ enum_string(IDLE),
+ enum_string(START),
+ enum_string(RUN),
+ enum_string(SUCCESS),
+ enum_string(FAILURE),
+ enum_string(DOWNLOAD),
+ enum_string(DONE),
+ enum_string(SUBPROCESS)
+ };
+
+ if (status >= ARRAY_SIZE(str))
+ return "UNKNOWN";
+
+ return str[status];
+}
+
+#define enum_source_string(x) [SOURCE_##x] = #x
+static const char *get_source_string(unsigned int source)
+{
+ const char * const str[] = {
+ enum_source_string(UNKNOWN),
+ enum_source_string(WEBSERVER),
+ enum_source_string(SURICATTA),
+ enum_source_string(DOWNLOADER),
+ enum_source_string(LOCAL)
+ };
+
+ if (source >= ARRAY_SIZE(str))
+ return "UNKNOWN";
+
+ return str[source];
+}
+
+static time_t get_monotonic_ms(void)
+{
+ struct timespec time_now;
+ clock_gettime(CLOCK_MONOTONIC, &time_now);
+
+ return time_now.tv_sec * 1000 + time_now.tv_nsec / 1000000;
+}
+
static void *process_notification_thread(void *data)
{
const int action_id = *(int *)data;
@@ -998,6 +1047,36 @@ static void *process_notification_thread(void *data)
unsigned int percent = 0;
unsigned int step = 0;
+ struct progress_msg progress_msg;
+ RECOVERY_STATUS progress_status = -1;
+ unsigned int progress_dwl_percent = 0;
+ size_t progress_dwl_total = 0;
+ unsigned int progress_step = 0;
+ unsigned int progress_total = 0;
+ unsigned int progress_percent = 0;
+ char progress_image_name[256];
+ sourcetype progress_source = -1;
+
+ char progress_str[512];
+ char progress_escaped[512];
+ int progress_fd = -1;
+ int progress_ret;
+ unsigned int field_size = 0;
+ const int NUMBER_OF_FIELDS = 3;
+ char *p_fields[NUMBER_OF_FIELDS];
+ const char *progress_detail = NULL;
+ bool progress_avail = false;
+ bool progress_pending = false;
+ bool flush_allowed = false;
+ bool flush_force = false;
+
+ time_t now_ms, last_flush_ms;
+ const unsigned int interval_ms = 3000;
+
+ p_fields[0] = &progress_str[0];
+
+ now_ms = get_monotonic_ms();
+ last_flush_ms = now_ms;
/*
* Create a new channel to the server. The opened channel is
@@ -1018,13 +1097,76 @@ static void *process_notification_thread(void *data)
bool data_avail = false;
int ret = ipc_get_status(&msg);
+ if (get_monotonic_ms() - last_flush_ms >= interval_ms)
+ flush_allowed = true;
+
+ if (progress_fd < 0)
+ progress_fd = progress_ipc_connect(true);
+
+ if (progress_fd >= 0) {
+ progress_ret = progress_ipc_receive(&progress_fd, &progress_msg);
+ if (progress_ret == sizeof(progress_msg)) {
+ if (progress_msg.status != PROGRESS &&
+ (progress_msg.status != progress_status || progress_msg.status == FAILURE)) {
+ progress_status = progress_msg.status;
+ progress_avail = true;
+ flush_force = true;
+ }
+
+ if (progress_msg.source != progress_source) {
+ progress_source = progress_msg.source;
+ progress_avail = true;
+ }
+
+ if ((progress_msg.dwl_percent != progress_dwl_percent) &&
+ progress_msg.dwl_bytes != 0 &&
+ progress_msg.status == DOWNLOAD) {
+ progress_dwl_percent = progress_msg.dwl_percent;
+ progress_dwl_total = progress_msg.dwl_bytes;
+ progress_avail = true;
+ }
+
+ if ((progress_msg.cur_step != progress_step ||
+ progress_msg.cur_percent != progress_percent) &&
+ progress_msg.cur_step) {
+ progress_step = progress_msg.cur_step;
+ progress_percent = progress_msg.cur_percent;
+ progress_total = progress_msg.nsteps;
+ snescape(progress_image_name, sizeof(progress_image_name), progress_msg.cur_step ? progress_msg.cur_image: "");
+ progress_avail = true;
+ }
+ }
+ if (progress_avail) {
+ p_fields[0] = progress_escaped;
+ field_size = snescape(p_fields[0], sizeof(progress_escaped), get_status_string(progress_status));
+
+ p_fields[1] = p_fields[0] + field_size + 1;
+ field_size = snescape(p_fields[1], sizeof(progress_escaped) - (p_fields[1] - p_fields[0]), get_source_string(progress_source));
+
+ p_fields[2] = p_fields[1] + field_size + 1;
+ field_size = snescape(p_fields[2], sizeof(progress_escaped) - (p_fields[2] - p_fields[0]), progress_image_name);
+
+ snprintf(progress_str, sizeof(progress_str),
+ "[suricatta_progress] { status : %s, source : %s, "
+ "download : { bytes : %zu, percent : %d }, "
+ "artifact : { current : %d, name : %s, percent : %d, total : %d } }",
+ p_fields[0], p_fields[1],
+ progress_dwl_total, progress_dwl_percent,
+ progress_step, p_fields[2], progress_percent, progress_total
+ );
+ progress_avail = false;
+ progress_pending = true;
+ }
+ }
+
if (ret < 0) {
ERROR("Error getting status, stopping notification thread");
stop = true;
} else {
data_avail = (strlen(msg.data.status.desc) != 0);
+ if (msg.data.status.current == PROGRESS)
+ data_avail = false;
}
-
/*
* Mutex used to synchronize end of the thread
* The suricatta thread locks the mutex at the beginning
@@ -1035,8 +1177,6 @@ static void *process_notification_thread(void *data)
stop = true;
}
- if (data_avail && msg.data.status.current == PROGRESS)
- continue;
if (data_avail && numdetails < MAX_DETAILS) {
for (int c = 0; c < strlen(msg.data.status.desc); c++) {
switch (msg.data.status.desc[c]) {
@@ -1051,11 +1191,11 @@ static void *process_notification_thread(void *data)
}
details[numdetails++] = strdup(msg.data.status.desc);
}
-
/*
* Flush to the server
*/
- if (numdetails == MAX_DETAILS || (stop && !data_avail)) {
+ if ((flush_allowed && !flush_force) &&
+ ((numdetails == MAX_DETAILS) || (stop && !data_avail && numdetails > 0))) {
TRACE("Update log to server from thread");
if (server_send_deployment_reply(
channel,
@@ -1074,9 +1214,32 @@ static void *process_notification_thread(void *data)
percent = 0;
step++;
}
+ flush_allowed = false;
+ last_flush_ms = get_monotonic_ms();
}
- if (stop && !data_avail)
+ if ((flush_allowed || flush_force) && progress_pending) {
+ progress_detail = strdup(progress_str);
+ if (server_send_deployment_reply(
+ channel,
+ action_id, step, percent,
+ reply_status_result_finished.none,
+ reply_status_execution.proceeding, 1,
+ &progress_detail) != SERVER_OK) {
+ ERROR("Error while sending log to server.");
+ }
+ free((void *)progress_detail);
+ progress_pending = false;
+ percent++;
+ if (percent > 100) {
+ percent = 0;
+ step++;
+ }
+ flush_allowed = false;
+ flush_force = false;
+ last_flush_ms = get_monotonic_ms();
+ }
+ if (stop && !data_avail && !progress_pending && numdetails == 0)
break;
// wait a bit for next message...
@@ -1265,7 +1428,7 @@ server_op_res_t server_process_update_artifact(int action_id,
artifact->url);
channel_data_t channel_data = channel_data_defaults;
- channel_data.url =
+ channel_data.url =
strdup(artifact->url);
static const char* const update_info = STRINGIFY(
[PATCH]---hawkbitUI---
ui: add target progress card for SWUpdate feedbackAdd a progress card to the target details modal that displays coarseSWUpdate/Suricatta deployment progress reported through hawkBit action-logdetails.The implementation introduces a target-progress parser for[suricatta_progress] detail entries and derives a UI model containing phase,source, download percentage, artifact step, artifact name, and byte counters.Only strict progress detail entries are parsed, preventing unrelated log linesor embedded feedback request dumps from being interpreted as progress.The target progress container polls the latest target action logs and rendersthe derived progress through a target-specific progress card and reusableprogress bar.diff --git a/src/app/components/progress-bar/index.tsx b/src/app/components/progress-bar/index.tsx
new file mode 100644
index 0000000..ae02e1f
--- /dev/null
+++ b/src/app/components/progress-bar/index.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import styles from './styles.module.scss';
+
+export interface ProgressBarProps {
+ value: number;
+ label?: string;
+ showValue?: boolean;
+ valueInside?: boolean;
+}
+
+function clampPercent(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+export default function ProgressBar({
+ value,
+ label,
+ showValue = true,
+ valueInside = true,
+}: ProgressBarProps) {
+ const percent = clampPercent(value);
+
+ return (
+ <div className={styles.container}>
+ {(label || (showValue && !valueInside)) && (
+ <div className={styles.header}>
+ {label && <span>{label}</span>}
+ {showValue && !valueInside && <strong>{percent}%</strong>}
+ </div>
+ )}
+
+ <div
+ className={styles.track}
+ aria-valuenow={percent}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ role='progressbar'
+ >
+ <div className={styles.fill} style={{ width: `${percent}%` }} />
+
+ {showValue && valueInside && (
+ <span className={styles.value}>{percent}%</span>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/components/progress-bar/styles.module.scss b/src/app/components/progress-bar/styles.module.scss
new file mode 100644
index 0000000..9229c67
--- /dev/null
+++ b/src/app/components/progress-bar/styles.module.scss
@@ -0,0 +1,40 @@
+.container {
+ width: 100%;
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ font-size: 14px;
+}
+
+.track {
+ position: relative;
+ width: 100%;
+ height: 24px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #e5e7eb;
+}
+
+.fill {
+ height: 100%;
+ border-radius: 999px;
+ background: #5b236b;
+ transition: width 250ms ease;
+}
+
+.value {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1;
+ color: white;
+ mix-blend-mode: difference;
+ pointer-events: none;
+}
diff --git a/src/app/components/target-info-modal/components/target-progress-card/index.tsx b/src/app/components/target-info-modal/components/target-progress-card/index.tsx
new file mode 100644
index 0000000..d29d4f6
--- /dev/null
+++ b/src/app/components/target-info-modal/components/target-progress-card/index.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import styles from './styles.module.scss';
+import Text from '@/app/components/text';
+import ProgressBar from '@/app/components/progress-bar';
+import { TargetProgress } from '@/entities/target-progress';
+
+export interface ProgressCardProps {
+ progress: TargetProgress | null;
+ title?: string;
+}
+
+function getStepLabel(progress: TargetProgress): string | null {
+ if (progress.totalSteps > 0 && progress.currentStep > 0) {
+ return `Step ${progress.currentStep} of ${progress.totalSteps}`;
+ }
+
+ return null;
+}
+
+function ProgressEmptyState({ title }: { title: string }) {
+ return (
+ <div className={styles.container}>
+ <Text variant='heading-2'>{title}</Text>
+ <div className={styles.empty}>No progress data available.</div>
+ </div>
+ );
+}
+
+function MetaItem({ children }: { children: React.ReactNode }) {
+ return <div className={styles.meta}>{children}</div>;
+}
+
+function SourceItem({ source }: { source?: string }) {
+ if (!source) {
+ return null;
+ }
+
+ return <MetaItem>Source: {source}</MetaItem>;
+}
+
+function BytesItem({
+ currentBytes,
+ totalBytes,
+}: {
+ currentBytes?: number;
+ totalBytes?: number;
+}) {
+ if (
+ currentBytes === undefined ||
+ totalBytes === undefined ||
+ !Number.isFinite(currentBytes) ||
+ !Number.isFinite(totalBytes) ||
+ totalBytes <= 0
+ ) {
+ return null;
+ }
+
+ return (
+ <div className={styles.common}>
+ ({currentBytes} of {totalBytes}) kB
+ </div>
+ );
+}
+
+export default function ProgressCard({
+ progress,
+ title = 'Progress',
+}: ProgressCardProps) {
+ if (!progress) {
+ return <ProgressEmptyState title={title} />;
+ }
+
+ const artifactName = progress.name.trim() || null;
+ const stepLabel = getStepLabel(progress);
+
+ return (
+ <div className={styles.container}>
+ <Text variant='heading-2'>{title}</Text>
+ <div className={styles.card}>
+ <div className={styles.header}>
+ <div className={styles.phase}>{progress.phase}</div>
+ <SourceItem source={progress.source} />
+ </div>
+ <ProgressBar value={progress.totalPercent} />
+ <div className={styles.metaBlock}>
+ <div className={styles.oneline}>
+ {artifactName && (
+ <div className={styles.common}>{artifactName}</div>
+ )}
+ <BytesItem
+ currentBytes={progress.currentBytes}
+ totalBytes={progress.totalBytes}
+ />
+ </div>
+ {stepLabel && <MetaItem>{stepLabel}</MetaItem>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/components/target-info-modal/components/target-progress-card/styles.module.scss b/src/app/components/target-info-modal/components/target-progress-card/styles.module.scss
new file mode 100644
index 0000000..8407ced
--- /dev/null
+++ b/src/app/components/target-info-modal/components/target-progress-card/styles.module.scss
@@ -0,0 +1,62 @@
+@use 'src/app/styles/variables' as *;
+
+.container {
+ width: 100%;
+}
+
+.card {
+ margin-top: 20px;
+ padding: 20px;
+ border-radius: 12px;
+ background-color: $secondary-color;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.oneline {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.current {
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.meta {
+ margin-top: 4px;
+ font-size: 13px;
+ opacity: 0.7;
+}
+
+.phase {
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 18px;
+ font-weight: 600;
+ text-transform: capitalize;
+ background: #e5e7eb;
+}
+
+.unknown {
+ background: #e5e7eb;
+}
+
+.common {
+ margin-top: 12px;
+ font-size: 13px;
+ opacity: 0.75;
+}
+
+.empty {
+ margin-top: 20px;
+ color: #777;
+}
diff --git a/src/app/components/target-info-modal/containers/target-progress-container/index.tsx b/src/app/components/target-info-modal/containers/target-progress-container/index.tsx
new file mode 100644
index 0000000..432e7da
--- /dev/null
+++ b/src/app/components/target-info-modal/containers/target-progress-container/index.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import ProgressCard from '../../components/target-progress-card';
+import {
+ deriveProgressFromDetails,
+ TargetProgress,
+} from '@/entities/target-progress';
+import { ActionLog } from '@/entities/action-log';
+import { TargetsService } from '@/services/targets-service';
+import { useTargetActionsTableStore } from '@/stores/target-action-table-store';
+
+function getLogTime(log: ActionLog): number {
+ const reportedAt = log.reportedAt;
+
+ if (!reportedAt) {
+ return 0;
+ }
+
+ if (typeof reportedAt === 'number') {
+ return reportedAt;
+ }
+
+ const parsed = Date.parse(reportedAt);
+
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function collectDetails(logs: ActionLog[]): string[] {
+ return logs
+ .slice()
+ .sort((a, b) => getLogTime(a) - getLogTime(b))
+ .flatMap((log) => log.messages ?? []);
+}
+
+function isTerminalProgress(progress: TargetProgress | null): boolean {
+ return progress?.phase === 'success' || progress?.phase === 'failure';
+}
+
+export default function TargetProgressContainer() {
+ const selectedTargetId = useTargetActionsTableStore(
+ (state) => state.selectedTargetId
+ );
+
+ const latestActionId = useTargetActionsTableStore(
+ (state) => state.actions[0]?.id
+ );
+
+ const [logs, setLogs] = useState<ActionLog[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (!selectedTargetId || !latestActionId) {
+ setLogs([]);
+ return;
+ }
+
+ let cancelled = false;
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
+
+ const fetchLogs = async () => {
+ setIsLoading(true);
+
+ try {
+ const actionLogs = await TargetsService.getActionLog(
+ selectedTargetId,
+ latestActionId
+ );
+
+ if (cancelled) {
+ return;
+ }
+
+ setLogs(actionLogs);
+
+ const progress = deriveProgressFromDetails(
+ collectDetails(actionLogs)
+ );
+
+ if (!isTerminalProgress(progress)) {
+ timeoutId = setTimeout(fetchLogs, 3000);
+ }
+ } catch (error) {
+ console.error('Failed to fetch progress logs', error);
+
+ if (!cancelled) {
+ timeoutId = setTimeout(fetchLogs, 5000);
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ fetchLogs();
+
+ return () => {
+ cancelled = true;
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, [selectedTargetId, latestActionId]);
+
+ const progress: TargetProgress | null = useMemo(() => {
+ return deriveProgressFromDetails(collectDetails(logs));
+ }, [logs]);
+
+ if (isLoading && !progress) {
+ return <ProgressCard progress={null} title='Progress' />;
+ }
+ return <ProgressCard progress={progress} title='Progress' />;
+}
diff --git a/src/app/components/target-info-modal/containers/target-progress-container/styles.module.scss b/src/app/components/target-info-modal/containers/target-progress-container/styles.module.scss
new file mode 100644
index 0000000..228de19
--- /dev/null
+++ b/src/app/components/target-info-modal/containers/target-progress-container/styles.module.scss
@@ -0,0 +1,59 @@
+.container {
+ width: 100%;
+}
+
+.title {
+ margin-bottom: 24px;
+ color: #5b236b;
+}
+
+.card {
+ padding: 20px;
+ border-radius: 12px;
+ background: #f7f7fb;
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.status {
+ font-weight: 600;
+}
+
+.source {
+ font-size: 14px;
+ color: #666;
+}
+
+.stepLabel {
+ margin-bottom: 8px;
+ font-size: 15px;
+}
+
+.progressOuter {
+ width: 100%;
+ height: 14px;
+ background: #e5e7eb;
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progressInner {
+ height: 100%;
+ background: #52d410;
+ transition: width 0.25s ease;
+}
+
+.progressText {
+ margin-top: 8px;
+ font-size: 14px;
+ color: #333;
+}
+
+.empty {
+ color: #777;
+}
diff --git a/src/app/components/target-info-modal/index.tsx b/src/app/components/target-info-modal/index.tsx
index 0eaa17b..c0468c9 100644
--- a/src/app/components/target-info-modal/index.tsx
+++ b/src/app/components/target-info-modal/index.tsx
@@ -6,6 +6,7 @@ import TargetAssignedDistributionContainer from '@/app/components/target-info-mo
import TargetInstalledDistributionContainer from '@/app/components/target-info-modal/containers/target-installed-distribution-container';
import TargetMetadataContainer from '@/app/components/target-info-modal/containers/target-metadata-container';
import TargetTags from '@/app/components/target-info-modal/components/target-tags';
+import TargetProgressContainer from '@/app/components/target-info-modal/containers/target-progress-container';
export default function TargetInfo() {
return (
@@ -26,6 +27,7 @@ export default function TargetInfo() {
},
{ title: 'Tags', component: <TargetTags /> },
{ title: 'Metadata', component: <TargetMetadataContainer /> },
+ { title: 'Progress', component: <TargetProgressContainer />},
]}
/>
);
diff --git a/src/entities/target-progress.ts b/src/entities/target-progress.ts
new file mode 100644
index 0000000..115225a
--- /dev/null
+++ b/src/entities/target-progress.ts
@@ -0,0 +1,241 @@
+type TargetProgressPhase =
+ | 'starting'
+ | 'download'
+ | 'install'
+ | 'success'
+ | 'failure'
+ | 'unknown';
+
+export interface TargetProgress {
+ phase: TargetProgressPhase;
+ source?: string;
+ name: string;
+ currentStep: number;
+ totalSteps: number;
+ stepPercent: number;
+ downloadPercent: number;
+ totalBytes: number;
+ currentBytes: number;
+ totalPercent: number;
+}
+
+interface SuricattaProgressDetail {
+ status: string;
+ source: string;
+ download: {
+ bytes: number;
+ percent: number;
+ };
+ artifact: {
+ current: number;
+ name: string;
+ percent: number;
+ total: number;
+ };
+}
+
+const SURICATTA_PROGRESS_DETAIL_RE =
+ /^\s*\[suricatta_progress\]\s*\{\s*status\s*:\s*[^,}]*,\s*source\s*:\s*[^,}]*,\s*download\s*:\s*\{[^}]*\}\s*,\s*artifact\s*:\s*\{[^}]*\}\s*\}\s*$/i;
+
+export function parseProgressDetail(
+ detail: string
+): SuricattaProgressDetail | null {
+
+ if (!SURICATTA_PROGRESS_DETAIL_RE.test(detail)) {
+ return null;
+ }
+
+ const status = extractValue(detail, 'status');
+ const source = extractValue(detail, 'source');
+ if (source != 'SURICATTA')
+ return null;
+
+ const downloadBlock = extractBlock(detail, 'download');
+ const artifactBlock = extractBlock(detail, 'artifact');
+
+ if (!status || !source || !downloadBlock || !artifactBlock) {
+ return null;
+ }
+
+ const downloadBytes = parseNumber(extractValue(downloadBlock, 'bytes'));
+ const downloadPercent = parseNumber(extractValue(downloadBlock, 'percent'));
+
+ const artifactCurrent = parseNumber(extractValue(artifactBlock, 'current'));
+ const artifactName = extractValue(artifactBlock, 'name') ?? '';
+ const artifactPercent = parseNumber(extractValue(artifactBlock, 'percent'));
+ const artifactTotal = parseNumber(extractValue(artifactBlock, 'total'));
+
+ if (
+ downloadBytes === null ||
+ downloadPercent === null ||
+ artifactCurrent === null ||
+ artifactPercent === null ||
+ artifactTotal === null
+ ) {
+ return null;
+ }
+
+ if (
+ downloadPercent < 0 ||
+ downloadPercent > 100 ||
+ artifactPercent < 0 ||
+ artifactPercent > 100 ||
+ artifactCurrent < 0 ||
+ artifactTotal < 0
+ ) {
+ return null;
+ }
+
+ if (artifactCurrent > 0 && artifactTotal <= 0) {
+ return null;
+ }
+
+ if (artifactTotal > 0 && artifactCurrent > artifactTotal) {
+ return null;
+ }
+
+ return {
+ status,
+ source,
+ download: {
+ bytes: downloadBytes,
+ percent: downloadPercent,
+ },
+ artifact: {
+ current: artifactCurrent,
+ name: artifactName,
+ percent: artifactPercent,
+ total: artifactTotal,
+ },
+ };
+}
+
+function findLatestTextProgress(
+ details: string[]
+): SuricattaProgressDetail | null {
+ for (let i = details.length - 1; i >= 0; i--) {
+ const detail = details[i];
+
+ if (!detail) {
+ continue;
+ }
+
+ const progress = parseProgressDetail(detail);
+
+ if (progress) {
+ return progress;
+ }
+ }
+
+ return null;
+}
+
+function deriveProgressFromText(
+ progress: SuricattaProgressDetail
+): TargetProgress {
+ const currentStep = progress.artifact.current;
+ const totalSteps = progress.artifact.total;
+ const stepPercent = clampPercent(progress.artifact.percent);
+ const downloadPercent = clampPercent(progress.download.percent);
+ const phase = derivePhase(progress.status, currentStep);
+
+ const installIsActive = currentStep > 0 && totalSteps > 0;
+
+ const totalPercent =
+ phase === 'success'
+ ? 100
+ : installIsActive
+ ? Math.round(((currentStep - 1) * 100 + stepPercent) / totalSteps)
+ : downloadPercent;
+
+ const totalBytes = Math.round(progress.download.bytes / 1024);
+ const currentBytes = Math.round((totalBytes * downloadPercent) / 100);
+
+ return {
+ phase,
+ source: progress.source,
+ name:
progress.artifact.name,
+ currentStep,
+ totalSteps,
+ stepPercent,
+ downloadPercent,
+ totalBytes,
+ currentBytes,
+ totalPercent: clampPercent(totalPercent),
+ };
+}
+
+export function deriveProgressFromDetails(
+ details: string[]
+): TargetProgress | null {
+ const latestTextProgress = findLatestTextProgress(details);
+
+ if (latestTextProgress) {
+ return deriveProgressFromText(latestTextProgress);
+ }
+
+ return null;
+}
+
+function clampPercent(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function parseNumber(value: string | null): number | null {
+ if (value === null || value.trim() === '') {
+ return null;
+ }
+
+ const num = Number(value.trim());
+
+ return Number.isFinite(num) ? num : null;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function extractBlock(input: string, blockName: string): string | null {
+ const re = new RegExp(
+ `${escapeRegExp(blockName)}\\s*:\\s*\\{([^}]*)\\}`,
+ 'i'
+ );
+
+ const match = input.match(re);
+
+ return match ? match[1] : null;
+}
+
+function extractValue(block: string, key: string): string | null {
+ const re = new RegExp(`${escapeRegExp(key)}\\s*:\\s*([^,}]*)`, 'i');
+ const match = block.match(re);
+
+ if (!match) {
+ return null;
+ }
+
+ return match[1].trim();
+}
+
+function derivePhase(status?: string, currentStep = 0): TargetProgressPhase {
+ switch (status?.toUpperCase()) {
+ case 'START':
+ return 'starting';
+ case 'DOWNLOAD':
+ return 'download';
+ case 'RUN':
+ return currentStep > 0 ? 'install' : 'starting';
+ case 'SUCCESS':
+ case 'DONE':
+ return 'success';
+ case 'FAILURE':
+ case 'ERROR':
+ return 'failure';
+ default:
+ return 'unknown';
+ }
+}
неділя, 12 квітня 2026 р. о 14:13:06 UTC+3 Stefano Babic пише: