function toEpochMs(iso) {
if (typeof iso !== "string") return null;
const ms = Date.parse(iso);
return Number.isNaN(ms) ? null : ms;
}
export function timeHms(iso) {
const ms = toEpochMs(iso);
if (ms === null) return "";
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function dateDmHms(iso) {
const ms = toEpochMs(iso);
if (ms === null) return "";
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function dateDmHm(iso) {
const ms = toEpochMs(iso);
if (ms === null) return "";
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function matchesTerm(term, fields) {
if (!term) return true;
return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(term));
}
export function hmJoin(hour, minute) {
let h = Math.floor(Number(hour) || 0);
let m = Math.floor(Number(minute) || 0);
if (h < 0) h = 0; else if (h > 23) h = 23;
if (m < 0) m = 0; else if (m > 59) m = 59;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export function hmSplit(hm) {
const match = /^(\d{1,2}):(\d{1,2})$/.exec(String(hm ?? ""));
if (!match) return [0, 0];
let h = Math.floor(Number(match[1]));
let m = Math.floor(Number(match[2]));
if (h < 0 || h > 23) h = 0;
if (m < 0 || m > 59) m = 0;
return [h, m];
}
export function fmtSecs(secs) {
const n = Math.floor(Number(secs) || 0);
const m = Math.floor(n / 60);
const s = n % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
export function fmtUptime(secs) {
const n = Math.floor(Number(secs) || 0);
const d = Math.floor(n / 86400);
const h = Math.floor((n % 86400) / 3600);
const m = Math.floor((n % 3600) / 60);
const s = n % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s}s`;
}
export function fmtDuration(dep) {
const start = toEpochMs(dep.started_at);
if (start === null) return "0s";
const finish = dep.finished_at ? toEpochMs(dep.finished_at) : Date.now();
const secs = Math.max(0, Math.floor(((finish ?? Date.now()) - start) / 1000));
return fmtSecs(secs);
}
export function stateLabelKind(state) {
if (state === "Live") return ["LIVE", "ok"];
if (state === "Stopped") return ["STOPPED", "muted"];
if (state === "Failed") return ["FAILED", "bad"];
if (state === "PreDeployCheck") return ["PRÉ-DEPLOY CHECK", "info"];
return ["BUILDING", "info"];
}
export function serviceStatusLabelKind(status) {
if (typeof status === "object" && status?.Error !== undefined) return ["Error", "bad"];
switch (status) {
case "Running":
return ["Running", "ok"];
case "Deploying":
return ["Deploying", "info"];
case "Queued":
return ["Na fila", "muted"];
case "Degraded":
return ["Degraded", "warn"];
case "Stopping":
case "Stopped":
return ["Stopped", "muted"];
default:
return ["Error", "bad"];
}
}
export function shortReason(msg, max = 140) {
if (typeof msg !== "string") return "";
for (const linha of msg.split(/\r?\n/)) {
const limpa = linha.trim();
if (limpa) return limpa.length > max ? limpa.slice(0, max - 1) + "…" : limpa;
}
return "";
}
export function fmtBytes(b) {
const n = Number(b) || 0;
if (n === 0) return "—";
const KB = 1024,
MB = KB * 1024,
GB = MB * 1024;
if (n >= GB) return (n / GB).toFixed(1) + " GB";
if (n >= MB) return (n / MB).toFixed(0) + " MB";
return (n / KB).toFixed(0) + " KB";
}
export function sourceSummary(source) {
if (!source || typeof source !== "object") return "—";
if (source.Registry) return source.Registry.image || "—";
if (source.Git) return `${source.Git.url} @ ${source.Git.branch}`;
if (source.Archive) return source.Archive.original_filename || source.Archive.archive_id || "zip enviado";
if (source.Compose) return "docker-compose";
return "—";
}
export function looksLikeGitUrl(u) {
const s = (u || "").trim();
return (
s.startsWith("https://") ||
s.startsWith("http://") ||
s.startsWith("git@") ||
s.startsWith("ssh://") ||
s.startsWith("file://") ||
s.endsWith(".git")
);
}
export function parseSecretRef(v) {
const s = (v || "").trim();
const m = s.match(/^<secret:(.+)>$/) || s.match(/^secret:(.+)$/);
if (!m) return null;
const name = m[1].trim();
return name || null;
}
export function dotenvFromVars(vars, comments) {
const activeVars = vars || [];
const activeComments = comments || [];
const lines = [];
for (const v of activeVars) {
for (const c of activeComments) {
if (c.before_key === v.key) lines.push(c.text);
}
const val = v.value?.Secret !== undefined ? `<secret:${v.value.Secret}>` : v.value?.Plain || "";
lines.push(`${v.key}=${val}`);
}
for (const c of activeComments) {
if (c.before_key === null || c.before_key === undefined) lines.push(c.text);
}
return lines.join("\n");
}
export function envRowsWithComments(vars, comments) {
const activeVars = vars || [];
const activeComments = comments || [];
const rows = [];
for (const v of activeVars) {
activeComments.forEach((c, i) => {
if (c.before_key === v.key) rows.push({ key: `__c${i}`, isComment: true, text: c.text });
});
rows.push({
key: v.key,
isComment: false,
value: v.value?.Plain ?? (v.value?.Secret ? `<secret:${v.value.Secret}>` : ""),
isSecret: !!v.value?.Secret,
});
}
activeComments.forEach((c, i) => {
if (c.before_key === null || c.before_key === undefined) {
rows.push({ key: `__c${i}`, isComment: true, text: c.text });
}
});
return rows;
}
export function stripAnsi(s) {
let str = s || "";
str = str.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, "");
str = str.replace(/\x1b\][^\x07]*\x07/g, "");
str = str.replace(/\x1b./g, "");
str = str.replace(/[\r\0]/g, "");
return str;
}
export function parseDotenv(text) {
const vars = [];
const comments = [];
let pending = [];
for (const raw of (text || "").split("\n")) {
const l = raw.trim();
if (l === "") continue;
if (l.startsWith("#")) {
pending.push(l);
continue;
}
const eq = l.indexOf("=");
if (eq < 0) continue;
const key = l.slice(0, eq).trim();
if (!key) continue;
const v = l.slice(eq + 1).trim();
for (const c of pending) comments.push({ text: c, before_key: key });
pending = [];
const secret = parseSecretRef(v);
vars.push({ key, value: secret ? { Secret: secret } : { Plain: v } });
}
for (const c of pending) comments.push({ text: c, before_key: null });
return { vars, comments };
}
export function safeName(name) {
let out = "";
let lastDash = true;
for (const ch of name || "") {
if (/[a-zA-Z0-9]/.test(ch)) {
out += ch.toLowerCase();
lastDash = false;
} else if (!lastDash) {
out += "_";
lastDash = true;
}
}
return out.replace(/^_+/, "").replace(/_+$/, "");
}
function internalScheme(dbKind) {
const k = (dbKind || "").toLowerCase();
if (k === "postgres" || k === "postgresql") return "postgresql";
if (k === "mysql" || k === "mariadb") return "mysql";
if (k === "redis") return "redis";
if (k === "mongodb" || k === "mongo") return "mongodb";
if (k === "rabbitmq") return "amqp";
if (k === "nats") return "nats";
return null; }
export function composeHost(content, ingressService) {
if (ingressService) return ingressService;
let inServices = false;
for (const line of String(content || "").split("\n")) {
if (!inServices) {
if (/^services:\s*$/.test(line)) inServices = true;
continue;
}
const m = line.match(/^\s+([\w.-]+):/);
if (m) return m[1];
if (!/^\s*$/.test(line) && !/^\s*#/.test(line)) return null;
}
return null;
}
export function internalUrl(dbKind, safe, port, composeHostName) {
const host = `${composeHostName || `rp_${safe}`}:${port}`;
const scheme = internalScheme(dbKind);
return scheme ? `${scheme}://${host}` : host;
}
function envPlain(vars, key) {
const v = (vars || []).find((e) => e.key === key);
return v?.value?.Plain || null;
}
function dbCredentials(dbKind, vars) {
const k = (dbKind || "").toLowerCase();
if (k === "postgres" || k === "postgresql") {
return [envPlain(vars, "POSTGRES_DB"), envPlain(vars, "POSTGRES_USER"), envPlain(vars, "POSTGRES_PASSWORD")];
}
if (k === "mysql" || k === "mariadb") {
return [envPlain(vars, "MYSQL_DATABASE"), envPlain(vars, "MYSQL_USER"), envPlain(vars, "MYSQL_PASSWORD")];
}
if (k === "mongodb" || k === "mongo") {
return [null, envPlain(vars, "MONGO_INITDB_ROOT_USERNAME"), envPlain(vars, "MONGO_INITDB_ROOT_PASSWORD")];
}
if (k === "redis") return [null, null, envPlain(vars, "REDIS_PASSWORD")];
if (k === "rabbitmq") return [null, envPlain(vars, "RABBITMQ_DEFAULT_USER"), envPlain(vars, "RABBITMQ_DEFAULT_PASS")];
return [null, null, null];
}
function withDbCredentials(base, database, user, password) {
let url = base;
if (database) url += `/${database}`;
const params = [];
if (user) params.push(`user=${user}`);
if (password) params.push(`password=${password}`);
if (params.length) url += `?${params.join("&")}`;
return url;
}
function pct(s) {
return encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
}
function userinfo(user, password) {
const u = user ? pct(user) : "";
const p = password ? pct(password) : "";
if (!u && !p) return "";
return p ? `${u}:${p}@` : `${u}@`;
}
function externalScheme(k) {
if (k === "mysql") return "mysql";
if (k === "mariadb") return "mariadb";
if (k === "redis") return "redis";
if (k === "mongodb" || k === "mongo") return "mongodb";
if (k === "rabbitmq") return "amqp";
if (k === "nats") return "nats";
return null;
}
function dbConnectionUrl(dbKind, host, port, database, user, password) {
const k = (dbKind || "").toLowerCase();
const hp = `${host}:${port}`;
if (k === "postgres" || k === "postgresql") {
return "jdbc:" + withDbCredentials(`postgresql://${hp}`, database, user, password);
}
const scheme = externalScheme(k);
if (!scheme) return hp;
let url = `${scheme}://${userinfo(user, password)}${hp}`;
if (database) url += `/${database}`;
else if (k === "mongodb" || k === "mongo") url += "/";
if ((k === "mongodb" || k === "mongo") && user) url += "?authSource=admin";
return url;
}
function urlHost(apiUrl) {
const u = (apiUrl || "").replace(/\s/g, "");
if (!u) return null;
const m = u.match(/^[a-zA-Z][\w+.-]*:\/\/([^:/]+)/);
return m ? m[1] : u.match(/^([^:/]+)/)?.[1] || null;
}
export function externalUrl(domain, tls, hostPort, dbKind, apiUrl, envVars) {
const [database, user, password] = dbCredentials(dbKind, envVars);
if (domain && domain.trim()) {
const clean = domain.replace(/\/+$/, "");
return `${tls ? "https" : "http"}://${clean}`;
}
if (hostPort) {
const host = urlHost(apiUrl) || "<host>";
return dbConnectionUrl(dbKind, host, String(hostPort), database, user, password);
}
return "—";
}
export function pairList(services) {
return (services || []).map((e) => ({ svc: e.service, proj: e.project_name || "" }));
}
export function domainRoutes(spec) {
if (spec.domains && spec.domains.length > 0) {
return spec.domains.map((r) => ({ domain: r.domain, port: r.port ?? null, tls: r.tls === true }));
}
if (spec.domain && spec.domain.trim()) {
return [{ domain: spec.domain, port: null, tls: spec.tls_enabled === true }];
}
return [];
}
export function ingressRows(pairs) {
const rows = [];
for (const p of pairs) {
const spec = p.svc.spec;
for (const r of domainRoutes(spec)) {
if (r.domain && r.domain.trim()) {
const cport = r.port ?? spec.port;
rows.push({
domain: r.domain,
url: `${r.tls ? "https" : "http"}://${r.domain}`,
service: spec.name,
project: p.proj,
upstream: `:${cport}`,
tls: r.tls ? "TLS" : "—",
});
}
}
}
return rows;
}
export function hostPortRows(pairs) {
const rows = [];
for (const p of pairs) {
const spec = p.svc.spec;
if (spec.host_port != null) {
rows.push({
service: spec.name,
project: p.proj,
hostPort: spec.host_port,
containerPort: spec.port,
});
}
}
rows.sort((a, b) => a.hostPort - b.hostPort);
return rows;
}
export function monitoringRows(pairs, metricsById) {
const rows = [];
for (const p of pairs) {
const m = metricsById && metricsById[p.svc.id];
if (m) {
rows.push({
name: p.svc.spec.name,
project: p.proj,
cpu: `${(m.cpu_percent || 0).toFixed(1)}%`,
mem: fmtBytes(m.mem_used_bytes),
rx: fmtBytes(m.net_rx_bytes),
tx: fmtBytes(m.net_tx_bytes),
});
}
}
return rows;
}
const DEPLOY_STEPS = [
{ label: "Fila", states: ["Pending"] },
{ label: "Checks", states: ["PreDeployCheck"] },
{ label: "Deps", states: ["ResolvingDeps"] },
{ label: "Obter", states: ["PullingImage", "CloningRepo", "BuildingImage", "ComposingUp"] },
{ label: "Staging", states: ["Staging"] },
{ label: "Health", states: ["HealthcheckPolling"] },
{ label: "Swap", states: ["SwappingIn", "Draining", "Promoting"] },
{ label: "Live", states: ["Live"] },
];
function stepOf(state) {
return DEPLOY_STEPS.findIndex((st) => st.states.includes(state));
}
export function deployStepper(info) {
const state = info.state;
let cur = stepOf(state);
let failed = -1;
if (state === "Live" || state === "Stopped" || state === "Pruning") {
cur = DEPLOY_STEPS.length;
} else if (state === "Failed" || state === "RollingBack") {
const log = info.states || [];
for (let i = log.length - 1; i >= 0; i--) {
const idx = stepOf(log[i].from);
if (idx >= 0) {
failed = idx;
break;
}
}
cur = failed;
}
return DEPLOY_STEPS.map((st, i) => ({
label: st.label,
status: i === failed ? "failed" : i < cur ? "done" : i === cur ? "current" : "pending",
}));
}
export function deployDetailRows(info) {
const log = info.states || [];
return log.map((t, i) => {
const start = Date.parse(t.at);
const end = i + 1 < log.length ? Date.parse(log[i + 1].at) : null;
const secs = end !== null ? Math.max(0, Math.round((end - start) / 1000)) : info.current_state_secs;
const last = i === log.length - 1;
return {
state: t.to,
label: t.to,
kind: t.to === "Failed" ? "bad" : last && info.state === t.to ? "info" : "ok",
dur: last && (t.to === "Failed" || t.to === "Live" || t.to === "Stopped") ? "—" : fmtSecs(secs),
msg: t.message || "",
};
});
}
export function engActiveRows(active) {
return (active || []).map((info) => {
const [label, kind] = stateLabelKind(info.state);
return {
service: info.service_name,
project: info.project_name,
stateLabel: label,
stateKind: kind,
steps: deployStepper(info),
detail: deployDetailRows(info),
total: fmtSecs(info.elapsed_secs),
phase: fmtSecs(info.current_state_secs),
serviceId: info.service_id,
};
});
}
export function engQueuedRows(queued) {
return (queued || []).map((info, i) => ({
deploymentId: info.deployment_id,
pos: i + 1,
service: info.service_name,
project: info.project_name,
}));
}
export function dockerContainerRows(list, term) {
const t = (term || "").toLowerCase();
const sorted = [...(list || [])].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
const rows = [];
for (const c of sorted) {
if (!matchesTerm(t, [c.name, c.image, c.state, c.project || "", c.service || ""])) continue;
const stateLabel = containerStateLabel(c.state);
let owner;
if (c.project && c.service) owner = `${c.project} / ${c.service}`;
else if (c.managed) owner = "rustploy";
else owner = "—";
const image = (c.image || "—").replace(/^sha256:/, "");
rows.push({
idFull: c.id || "",
id: (c.id || "").slice(0, 12),
name: c.name || "—",
image,
owner,
stateLabel: stateLabel[0],
stateKind: stateLabel[1],
canRemove: containerStopped(c.state),
});
}
return rows;
}
function containerStateLabel(state) {
const s = (state || "").toLowerCase();
if (s === "running") return ["running", "ok"];
if (s === "restarting") return ["restarting", "info"];
if (s === "paused") return ["paused", "warn"];
if (s === "created") return ["created", "info"];
if (s === "dead") return ["dead", "bad"];
return [s || "exited", "muted"];
}
function containerStopped(state) {
const s = (state || "").toLowerCase();
return s !== "running" && s !== "restarting" && s !== "paused";
}
export function dockerImageRows(list, term) {
const t = (term || "").toLowerCase();
const sorted = [...(list || [])].sort((a, b) => (a.tags || []).join(",").localeCompare((b.tags || []).join(",")));
const rows = [];
for (const img of sorted) {
const tagStr = (img.tags || []).join(" ");
if (!matchesTerm(t, [tagStr, img.project || "", img.service || ""])) continue;
const inUse = (img.containers || 0) > 0;
const id = (img.id || "").replace(/^sha256:/, "");
rows.push({
id: id.slice(0, 12),
idFull: id,
tags: (img.tags || []).length ? img.tags.join(", ") : "<none>",
size: fmtBytes(img.size_bytes),
created: dateDmHm(img.created),
project: img.project || "—",
service: img.service || "—",
owner: `${img.project || "—"} / ${img.service || "—"}`,
inUse,
inUseLabel: inUse ? "EM USO" : "SEM USO",
inUseKind: inUse ? "ok" : "muted",
});
}
return rows;
}
export function dockerVolumeRows(list, term) {
const t = (term || "").toLowerCase();
const sorted = [...(list || [])].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
const rows = [];
for (const v of sorted) {
if (!matchesTerm(t, [v.name, v.driver])) continue;
rows.push({
name: v.name,
nameFull: v.name,
driver: v.driver,
mountpoint: v.mountpoint,
size: v.size_bytes != null && v.size_bytes >= 0 ? fmtBytes(v.size_bytes) : "—",
inUse: !!v.in_use,
inUseLabel: v.in_use ? "EM USO" : "SEM USO",
inUseKind: v.in_use ? "ok" : "muted",
});
}
return rows;
}
export function dockerNetworkRows(list, term) {
const t = (term || "").toLowerCase();
const sorted = [...(list || [])].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
const rows = [];
for (const n of sorted) {
if (!matchesTerm(t, [n.name, n.project || ""])) continue;
rows.push({
name: n.name,
idFull: n.id || "",
driver: n.driver,
scope: n.scope,
project: n.project || "—",
containers: n.container_count || 0,
inUse: !!n.in_use,
inUseLabel: n.in_use ? "EM USO" : "SEM USO",
inUseKind: n.in_use ? "ok" : "muted",
});
}
return rows;
}
export function registryRepoRows(list, term) {
const t = (term || "").toLowerCase();
const rows = [];
for (const r of list || []) {
if (!matchesTerm(t, [r.name])) continue;
rows.push({
name: r.name,
tagCount: r.tag_count || 0,
size: fmtBytes(r.size_bytes || 0),
created: dateDmHm(r.created_at),
});
}
return rows;
}
export function registryTagRows(list) {
return (list || []).map((t) => ({
tag: t.tag,
digest: (t.digest || "").slice(0, 12),
digestFull: t.digest,
size: fmtBytes(t.size_bytes || 0),
updated: dateDmHm(t.updated_at),
}));
}
export function registryTokenRows(list) {
return (list || []).map((t) => ({
name: t.name,
scope: t.scope,
created: dateDmHm(t.created_at),
lastUsed: t.last_used_at ? dateDmHm(t.last_used_at) : "nunca",
}));
}
const WEEKDAYS = ["seg", "ter", "qua", "qui", "sex", "sáb", "dom"];
export function recurrenceLabel(r) {
if (r == null) return "manual";
if (r.IntervalHours != null) return `a cada ${r.IntervalHours}h`;
if (r.Daily) {
const pad = (n) => String(n).padStart(2, "0");
return `diariamente às ${pad(r.Daily.hour)}:${pad(r.Daily.minute)}`;
}
if (r.Weekly) {
const pad = (n) => String(n).padStart(2, "0");
const wd = WEEKDAYS[((r.Weekly.weekday % 7) + 7) % 7] || "?";
return `semanalmente ${wd} às ${pad(r.Weekly.hour)}:${pad(r.Weekly.minute)}`;
}
return "—";
}
export function jobRunStateLabel(run) {
if (run == null) return ["nunca rodou", "muted"];
if (run.success == null) return ["rodando…", "warn"];
return run.success ? ["ok", "ok"] : ["falhou", "bad"];
}
export function jobSummaryRows(list, term, inflight) {
const t = (term || "").toLowerCase();
const rows = [];
for (const s of list || []) {
const job = s.job;
if (!matchesTerm(t, [job.name, s.project_name, s.trigger_service_name || ""])) continue;
const [lastRunLabel, lastRunKind] = jobRunStateLabel(s.last_run);
rows.push({
id: job.id,
name: job.name,
project: s.project_name,
owner: s.trigger_service_name ? `${s.project_name} / ${s.trigger_service_name}` : s.project_name,
recurrence: recurrenceLabel(job.recurrence),
enabled: job.enabled,
enabledLabel: job.enabled ? "Pausar" : "Ativar",
lastRunLabel,
lastRunKind,
running: lastRunKind === "warn" || !!(inflight && inflight[job.id]),
lastRunId: s.last_run ? s.last_run.id : "",
nextRunAt: job.next_run_at ? dateDmHm(job.next_run_at) : "—",
});
}
return rows;
}
export function oauthRedirectUri(base, kind) {
const seg = kind === "github" ? "github" : "gitea";
const d = (base || "").trim().replace(/\/+$/, "");
if (d === "") return `<URL pública do daemon indisponível>/oauth/${seg}/callback`;
return `${d}/oauth/${seg}/callback`;
}
function gitKindLabel(kind) {
if (kind === "Github") return "GitHub";
if (kind === "Gitea") return "Gitea";
return kind || "Git";
}
export function gitProviderRows(list) {
return (list || []).map((p) => {
const login = p.account?.login;
const connected = login != null;
return {
id: p.id,
name: p.name,
kind: gitKindLabel(p.kind),
display: connected ? `${p.name || ""} (@${login})` : `${p.name || ""} — não conectado`,
baseUrl: p.base_url,
authMode: p.auth_mode === "OAuth" ? "OAuth2" : "PAT",
account: connected ? `@${login}` : "(pendente — autorize no navegador)",
connected,
};
});
}
const DOCKER_CLEANUP_LABELS = {
containers: "containers",
images: "imagens",
volumes: "volumes",
networks: "redes",
build_cache: "cache de build",
};
export function dockerCleanupResourceLabel(name) {
return DOCKER_CLEANUP_LABELS[name] || name;
}
export function dockerCleanupLastRunSummary(lr) {
if (!lr) return "ainda não rodou";
let totalBytes = 0;
let anyError = false;
const parts = (lr.results || []).map((r) => {
totalBytes += r.reclaimed_bytes || 0;
if (r.error != null) {
anyError = true;
return `${dockerCleanupResourceLabel(r.resource)}: erro`;
}
return `${dockerCleanupResourceLabel(r.resource)} ${r.count || 0}`;
});
const head = `${dateDmHms(lr.at)} · ${fmtBytes(totalBytes)} liberados`;
if (parts.length === 0) return head;
let suffix = ` (${parts.join(", ")})`;
if (anyError) suffix += " — alguns recursos falharam, ver logs do daemon";
return head + suffix;
}
export function engRecentRows(recent) {
return (recent || []).map((info) => {
const [label, kind] = stateLabelKind(info.state);
let icon = "○";
if (info.state === "Live") icon = "✓";
else if (info.state === "Failed") icon = "✕";
return {
icon,
service: info.service_name,
project: info.project_name,
stateLabel: label,
stateKind: kind,
duration: fmtSecs(info.elapsed_secs),
start: timeHms(info.started_at),
};
});
}