fix: honest costs in streams, oracle-sized timeouts, history & log cleanup
Cost showed $0 for every streamed request: the X-Cortiq-Cost-USD header is written before generation, when the cost cannot be known. The gateway now ships the final number in a closing SSE event (`cortiq_cost_usd`, alongside the token estimate when the provider sent no usage), and the console prefers it over the header. Money renders with 4 decimals, the last two in a smaller font — $0.0006 stays readable instead of collapsing to $0. Timeouts, both ends: the router default was 800 ms, silently degrading every prompt the hosted router escalates to its oracle (~10 s) — now 15 s. Provider clients had NO timeout at all (a dead OpenRouter host hung requests forever) — now a 10 s connect timeout, with no overall cap so long generations and streams are never cut. Benchmark history: per-run × and a Clear-all button. Logs view: a clear button backed by DELETE /admin/api/logs.
This commit is contained in:
+6
-1
@@ -185,7 +185,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
.route("/admin/api/requests", get(get_requests))
|
||||
.route("/admin/api/test", post(run_test))
|
||||
.route("/admin/api/test/stream", post(run_test_stream))
|
||||
.route("/admin/api/logs", get(get_logs))
|
||||
.route("/admin/api/logs", get(get_logs).delete(clear_logs))
|
||||
.route("/admin/api/system", get(get_system))
|
||||
.route("/admin/api/restart", post(restart_gateway))
|
||||
.route("/admin/api/bench", get(bench_list).post(bench_start))
|
||||
@@ -2113,6 +2113,11 @@ async fn get_logs(Query(q): Query<RecentQuery>) -> ApiResult {
|
||||
ok(json!({ "lines": crate::logbuf::snapshot(limit) }))
|
||||
}
|
||||
|
||||
async fn clear_logs() -> ApiResult {
|
||||
crate::logbuf::clear();
|
||||
ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Host metrics snapshot: CPU, RAM, free disk at the models dir, gateway RSS.
|
||||
async fn get_system(State(state): State<SharedState>) -> ApiResult {
|
||||
let snap = state.sysmon.snapshot();
|
||||
|
||||
+4
-2
@@ -305,7 +305,9 @@ fn default_router_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_router_timeout() -> u64 {
|
||||
800
|
||||
// the hosted router escalates hard prompts to an oracle (~10 s);
|
||||
// an 800 ms budget silently degraded every one of those to the fallback
|
||||
15_000
|
||||
}
|
||||
fn default_verify_tls() -> bool {
|
||||
true
|
||||
@@ -736,7 +738,7 @@ impl Default for Config {
|
||||
router: RouterCfg {
|
||||
enabled: true,
|
||||
url: "https://138.226.222.209".to_string(),
|
||||
timeout_ms: 12_000, // hard prompts escalate to an oracle (~10 s)
|
||||
timeout_ms: 15_000, // hard prompts escalate to an oracle (~10 s)
|
||||
verify_tls: false, // the hosted endpoint is IP-bound
|
||||
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
|
||||
taxonomy_id: Some("data-assistant".to_string()),
|
||||
|
||||
@@ -23,6 +23,11 @@ pub fn snapshot(limit: usize) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop everything in the ring (the admin console's "clear" button).
|
||||
pub fn clear() {
|
||||
ring().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
pub fn push_line(line: &str) {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() {
|
||||
|
||||
+24
-17
@@ -798,28 +798,35 @@ fn tap_stream(
|
||||
// inject one final OpenAI-shaped usage event with the gateway's
|
||||
// estimate (~4 chars/token), flagged `cortiq_estimated` so nobody
|
||||
// mistakes it for the tokenizer's truth.
|
||||
if done_marker && !got_usage {
|
||||
let est_completion = if delta_chunks > 0 {
|
||||
delta_chunks
|
||||
} else {
|
||||
(delta_chars / 4) as u32
|
||||
};
|
||||
let est_prompt = (prompt_chars / 4) as u32;
|
||||
let evt = serde_json::json!({
|
||||
if done_marker {
|
||||
// The initial X-Cortiq-Cost-USD header is always 0 — cost is
|
||||
// only known after generation. Ship the final number (and the
|
||||
// token estimate when the provider sent none) ahead of [DONE].
|
||||
if !got_usage {
|
||||
prompt_tokens = (prompt_chars / 4) as u32;
|
||||
completion_tokens = if delta_chunks > 0 {
|
||||
delta_chunks
|
||||
} else {
|
||||
(delta_chars / 4) as u32
|
||||
};
|
||||
}
|
||||
let cost = provider.price(prompt_tokens, completion_tokens);
|
||||
let mut evt = serde_json::json!({
|
||||
"object": "chat.completion.chunk",
|
||||
"model": info.selected_model,
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": est_prompt,
|
||||
"completion_tokens": est_completion,
|
||||
"total_tokens": est_prompt + est_completion,
|
||||
},
|
||||
"cortiq_estimated": true,
|
||||
"cortiq_cost_usd": cost,
|
||||
});
|
||||
if !got_usage {
|
||||
evt["usage"] = serde_json::json!({
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
});
|
||||
evt["cortiq_estimated"] = serde_json::json!(true);
|
||||
got_usage = true;
|
||||
}
|
||||
yield Ok(bytes::Bytes::from(format!("data: {evt}\n\n")));
|
||||
prompt_tokens = est_prompt;
|
||||
completion_tokens = est_completion;
|
||||
got_usage = true;
|
||||
}
|
||||
yield item;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,13 @@ impl AnthropicProvider {
|
||||
caps: Caps::from_list(&cfg.caps),
|
||||
price_in: cfg.price_in,
|
||||
price_out: cfg.price_out,
|
||||
http: reqwest::Client::new(),
|
||||
// connect timeout: a dead host fails over in seconds instead of
|
||||
// hanging the request; NO overall timeout — that would cut long
|
||||
// generations and streams (OpenRouter can be slow legitimately)
|
||||
http: reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,13 @@ impl OpenAiProvider {
|
||||
caps: Caps::from_list(&cfg.caps),
|
||||
price_in: cfg.price_in,
|
||||
price_out: cfg.price_out,
|
||||
http: reqwest::Client::new(),
|
||||
// connect timeout: a dead host fails over in seconds instead of
|
||||
// hanging the request; NO overall timeout — that would cut long
|
||||
// generations and streams (OpenRouter can be slow legitimately)
|
||||
http: reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
@@ -585,6 +585,9 @@ body.modal-open { overflow: hidden; }
|
||||
/* Featured gallery: the list grows as new models are published, so give it more
|
||||
room than the search grid before it starts scrolling. */
|
||||
.featured-section .hf-grid { max-height: min(62vh, 760px); }
|
||||
/* Money: 4 decimals, last two de-emphasized */
|
||||
.money-tail { font-size: .72em; opacity: .75; margin-left: 1px; }
|
||||
|
||||
/* Benchmark view */
|
||||
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
tbody tr:last-child { animation: bm-in .25s ease; }
|
||||
|
||||
Vendored
+4
-1
@@ -130,6 +130,7 @@ export const api = {
|
||||
// system / logs / lifecycle
|
||||
system: () => j("GET", "/system"),
|
||||
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
||||
clearLogs: () => j("DELETE", "/logs"),
|
||||
restart: () => j("POST", "/restart"),
|
||||
benchStart: (model) => j("POST", "/bench", { model }),
|
||||
benchList: () => j("GET", "/bench"),
|
||||
@@ -190,6 +191,7 @@ export async function testStream(body, onDelta) {
|
||||
let ttftMs = null; // time to first content token
|
||||
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
|
||||
let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth
|
||||
let costUsd = null; // final cost from the gateway's closing event (headers carry 0)
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -207,6 +209,7 @@ export async function testStream(body, onDelta) {
|
||||
usage = chunk.usage;
|
||||
usageEstimated = !!chunk.cortiq_estimated;
|
||||
}
|
||||
if (chunk.cortiq_cost_usd != null) costUsd = chunk.cortiq_cost_usd;
|
||||
const d = chunk.choices?.[0]?.delta?.content;
|
||||
if (d) {
|
||||
if (ttftMs == null) ttftMs = Math.round(performance.now() - t0);
|
||||
@@ -224,7 +227,7 @@ export async function testStream(body, onDelta) {
|
||||
score: parseFloat(h.get("x-cortiq-complexity-score") || "0"),
|
||||
selected_model: h.get("x-cortiq-selected-model") || "",
|
||||
route_source: h.get("x-cortiq-route-source") || "",
|
||||
cost_usd: parseFloat(h.get("x-cortiq-cost-usd") || "0"),
|
||||
cost_usd: costUsd != null ? costUsd : parseFloat(h.get("x-cortiq-cost-usd") || "0"),
|
||||
usage,
|
||||
usage_estimated: usageEstimated,
|
||||
ttft_ms: ttftMs,
|
||||
|
||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
||||
"bm.liveRunning": "läuft…",
|
||||
"bm.spent": "ausgegeben",
|
||||
"bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}",
|
||||
"bm.clearHistory": "Alle löschen",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -594,4 +594,5 @@ export default {
|
||||
"bm.liveRunning": "running…",
|
||||
"bm.spent": "spent",
|
||||
"bm.verdict": "🏁 Done: answers {p}, spent {s}",
|
||||
"bm.clearHistory": "Clear all",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
||||
"bm.liveRunning": "en curso…",
|
||||
"bm.spent": "gastado",
|
||||
"bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}",
|
||||
"bm.clearHistory": "Borrar todo",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
||||
"bm.liveRunning": "en cours…",
|
||||
"bm.spent": "dépensé",
|
||||
"bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}",
|
||||
"bm.clearHistory": "Tout effacer",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -593,4 +593,5 @@ export default {
|
||||
"bm.liveRunning": "идёт…",
|
||||
"bm.spent": "потрачено",
|
||||
"bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}",
|
||||
"bm.clearHistory": "Очистить всё",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
||||
"bm.liveRunning": "sürüyor…",
|
||||
"bm.spent": "harcanan",
|
||||
"bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}",
|
||||
"bm.clearHistory": "Tümünü temizle",
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -601,4 +601,5 @@ export default {
|
||||
"bm.liveRunning": "进行中……",
|
||||
"bm.spent": "花费",
|
||||
"bm.verdict": "🏁 完成:回答 {p},花费 {s}",
|
||||
"bm.clearHistory": "全部清除",
|
||||
};
|
||||
|
||||
Vendored
+13
-2
@@ -95,11 +95,22 @@ export function confirmDialog(message) {
|
||||
}
|
||||
|
||||
// number formatting
|
||||
// Money as a DOM node: 4 decimals, the last two rendered smaller — tiny
|
||||
// per-request prices stay readable without shouting six zeros.
|
||||
export function moneyEl(n) {
|
||||
if (n == null) return document.createTextNode("—");
|
||||
if (n === 0) return document.createTextNode("$0");
|
||||
const s = n.toFixed(4);
|
||||
return h("span", { class: "money" }, "$" + s.slice(0, -2), h("small", { class: "money-tail" }, s.slice(-2)));
|
||||
}
|
||||
|
||||
export function money(n) {
|
||||
if (n == null) return "—";
|
||||
if (n === 0) return "$0";
|
||||
if (Math.abs(n) < 0.01) return "$" + n.toFixed(6);
|
||||
return "$" + n.toFixed(4);
|
||||
// up to 4 decimals, trailing zeros trimmed (but keep money-like 2 minimum)
|
||||
let s = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
||||
if (/\.\d$/.test(s)) s += "0";
|
||||
return "$" + s;
|
||||
}
|
||||
export function ms(n) {
|
||||
return n == null ? "—" : `${Math.round(Number(n))} ms`;
|
||||
|
||||
Vendored
+24
-8
@@ -7,7 +7,7 @@
|
||||
// A redesign of the old standalone benchmark.html: same scoring ideas
|
||||
// (keyword checks with | synonyms, think-tag stripping, anti-loop detection,
|
||||
// pass/partial/fail), now embedded, localized and CDN-free.
|
||||
import { h, mount, toast, ms, money } from "../ui.js";
|
||||
import { h, mount, toast, ms, money, moneyEl } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api, testStream } from "../api.js";
|
||||
|
||||
@@ -60,6 +60,9 @@ function pushHistory(run) {
|
||||
l.unshift(run);
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(l.slice(0, 12)));
|
||||
}
|
||||
function saveHistory(l) {
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(l));
|
||||
}
|
||||
|
||||
// ── evaluation (ported from the old benchmark, trimmed) ─────────────────────
|
||||
function stripThink(text) {
|
||||
@@ -261,11 +264,11 @@ export async function renderBenchmark() {
|
||||
h("div", { class: "kpi" }, h("div", { class: "big" }, judged.length ? `${okCount}/${judged.length}` : "—"), h("div", { class: "lbl" }, t("bm.classAcc"))),
|
||||
h("div", { class: "kpi" }, h("div", { class: "big" }, st.ttft != null ? ms(Math.round(st.ttft)) : "—"), h("div", { class: "lbl" }, t("pg.ttft"))),
|
||||
h("div", { class: "kpi" }, h("div", { class: "big" }, st.tokS != null ? st.tokS.toFixed(1) : "—"), h("div", { class: "lbl" }, "tok/s")),
|
||||
h("div", { class: "kpi" }, h("div", { class: "big" }, money(st.cost)), h("div", { class: "lbl" }, t("bm.spent")))),
|
||||
h("div", { class: "kpi" }, h("div", { class: "big" }, moneyEl(st.cost)), h("div", { class: "lbl" }, t("bm.spent")))),
|
||||
h("div", { class: "flex wrap", style: "gap:6px; margin-top:4px" },
|
||||
h("span", { class: "muted small" }, t("bm.modelDist") + ":"),
|
||||
...Object.entries(dist).map(([m, d]) =>
|
||||
h("span", { class: "badge" }, `${m} × ${d.n}` + (d.cost > 0 ? ` · ${money(d.cost)}` : "")))));
|
||||
h("span", { class: "badge" }, `${m} × ${d.n}`, d.cost > 0 ? " · " : "", d.cost > 0 ? moneyEl(d.cost) : ""))));
|
||||
} else {
|
||||
const sorted = models.slice().sort((a, b) => stats(run.models[b]).score - stats(run.models[a]).score);
|
||||
summary = h("div", {},
|
||||
@@ -278,7 +281,7 @@ export async function renderBenchmark() {
|
||||
h("span", { class: "badge " + (pct >= 80 ? "ok" : pct >= 50 ? "" : "bad") }, pct + "%"),
|
||||
st.tokS != null ? h("span", { class: "muted small", style: "margin-left:8px" }, st.tokS.toFixed(1) + " tok/s") : null,
|
||||
st.ttft != null ? h("span", { class: "muted small", style: "margin-left:8px" }, "TTFT " + ms(Math.round(st.ttft))) : null,
|
||||
st.cost > 0 ? h("span", { class: "muted small", style: "margin-left:8px" }, t("bm.spent") + " " + money(st.cost)) : null),
|
||||
st.cost > 0 ? h("span", { class: "muted small", style: "margin-left:8px" }, t("bm.spent") + " ", moneyEl(st.cost)) : null),
|
||||
h("div", { class: "sysbar" }, h("i", { class: pct < 50 ? "bad" : "", style: `width:${pct}%` })));
|
||||
}));
|
||||
}
|
||||
@@ -316,7 +319,7 @@ export async function renderBenchmark() {
|
||||
h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"),
|
||||
h("td", {},
|
||||
h("span", { class: "mono small" }, r.chosen || "—"),
|
||||
r.cost > 0 ? h("div", { class: "muted small" }, money(r.cost)) : null,
|
||||
r.cost > 0 ? h("div", { class: "muted small" }, moneyEl(r.cost)) : null,
|
||||
r.source && r.source.includes("task-rule") ? h("div", { class: "badge", style: "margin-top:2px" }, "task-rule") : null),
|
||||
answerCell(r, r.chosen || "cortiq-auto")));
|
||||
} else {
|
||||
@@ -350,9 +353,16 @@ export async function renderBenchmark() {
|
||||
mount(historyHost);
|
||||
return;
|
||||
}
|
||||
const clearAll = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("bm.clearHistory"));
|
||||
clearAll.addEventListener("click", () => {
|
||||
saveHistory([]);
|
||||
renderHistory();
|
||||
});
|
||||
mount(historyHost, h("div", { class: "card" },
|
||||
h("div", { class: "card-title" }, t("bm.history")),
|
||||
...l.map((run) => {
|
||||
h("div", { class: "card-head" },
|
||||
h("h3", {}, t("bm.history")),
|
||||
h("div", { class: "right" }, clearAll)),
|
||||
...l.map((run, idx) => {
|
||||
const models = Object.keys(run.models);
|
||||
const scoreOf = (m) => {
|
||||
const rs = run.models[m].results || [];
|
||||
@@ -364,7 +374,13 @@ export async function renderBenchmark() {
|
||||
h("span", { class: "muted small" }, new Date(run.ts).toLocaleString()),
|
||||
h("span", { class: "grow mono small" }, run.mode === "routing" ? t("bm.mode.routing") : models.join(", ")),
|
||||
run.quick ? h("span", { class: "badge" }, t("bm.quick")) : null,
|
||||
best ? h("b", {}, Math.round(scoreOf(best) * 100) + "%") : null);
|
||||
best ? h("b", {}, Math.round(scoreOf(best) * 100) + "%") : null,
|
||||
h("button", { class: "btn sm ghost danger", title: t("common.delete"), onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
l.splice(idx, 1);
|
||||
saveHistory(l);
|
||||
renderHistory();
|
||||
} }, "×"));
|
||||
row.addEventListener("click", () => renderResults(run));
|
||||
return row;
|
||||
})));
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
||||
import { h, mount, money, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
|
||||
import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { lineChart, barList } from "../charts.js";
|
||||
@@ -161,7 +161,7 @@ export async function renderDashboard() {
|
||||
{ class: "kpis" },
|
||||
kpi(num(tot.requests), t("dash.kpi.requests")),
|
||||
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
|
||||
kpi(money(tot.cost_usd), t("dash.kpi.cost")),
|
||||
kpi(moneyEl(tot.cost_usd), t("dash.kpi.cost")),
|
||||
kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")),
|
||||
kpi(pct(tot.success_rate), t("dash.kpi.success")),
|
||||
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
||||
|
||||
Vendored
+12
-1
@@ -21,6 +21,17 @@ export function renderLogs() {
|
||||
);
|
||||
const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause"));
|
||||
const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
|
||||
const clearBtn = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("dash.clear"));
|
||||
clearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await api.clearLogs();
|
||||
lines = [];
|
||||
paint();
|
||||
toast(t("dash.cleared"), "ok");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "err");
|
||||
}
|
||||
});
|
||||
|
||||
let lines = [];
|
||||
function paint() {
|
||||
@@ -76,7 +87,7 @@ export function renderLogs() {
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn),
|
||||
h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn, clearBtn),
|
||||
pre
|
||||
)
|
||||
);
|
||||
|
||||
Vendored
+2
-2
@@ -4,7 +4,7 @@
|
||||
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
|
||||
// Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas
|
||||
// player synced to the wav) — the same no-ffmpeg stance as the engine itself.
|
||||
import { h, mount, toast, money, ms } from "../ui.js";
|
||||
import { h, mount, toast, money, moneyEl, ms } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
@@ -218,7 +218,7 @@ export async function renderPlayground() {
|
||||
kv(t("pg.tokens"), haveUsage ? `${r.usage_estimated ? "≈ " : ""}${u.prompt_tokens ?? 0} / ${u.completion_tokens ?? 0}` : "—"),
|
||||
kv(t("pg.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"),
|
||||
kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"),
|
||||
kv(t("pg.cost"), money(c.cost_usd)),
|
||||
kv(t("pg.cost"), moneyEl(c.cost_usd)),
|
||||
kv(t("pg.latency"), ms(r.latency_ms)),
|
||||
kv(t("pg.failover"), c.failover ? "yes" : "no")
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user