chore: ship the full working frontend + backend

The crates.io/release build shipped a STALE frontend (committed web/js was behind
the working tree), so the admin console hung on load. Commit the current working
web/js (app, api, dashboard, login, import view, i18n de/es/fr/tr/zh, css) and the
backend WIP (import.rs, admin/assets.rs) so the released console matches the dev
build. Verified: clean `cargo build --release`, empty-pool start, working
dashboard served.
This commit is contained in:
infosave2007
2026-07-08 17:58:31 +03:00
parent d2b1d50eb8
commit 749a47ce9b
15 changed files with 597 additions and 18 deletions
+16
View File
@@ -50,6 +50,7 @@ export const api = {
// meta / health
meta: () => j("GET", "/meta"),
health: () => j("GET", "/health"),
probeRouter: () => j("POST", "/router/probe"),
// config
getConfig: () => j("GET", "/config"),
@@ -90,6 +91,21 @@ export const api = {
// playground
test: (b) => j("POST", "/test", b),
// shadow / self-warming loop
shadow: () => j("GET", "/shadow"),
// CMF model factory (HuggingFace → local .cmf)
hfSearch: (q, limit = 24) =>
j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`),
startImport: (b) => j("POST", "/import", b),
listImports: () => j("GET", "/import"),
importStatus: (job) => j("GET", "/import/" + encodeURIComponent(job)),
cancelImport: (job) =>
j("POST", "/import/" + encodeURIComponent(job) + "/cancel"),
deleteImport: (job) => j("DELETE", "/import/" + encodeURIComponent(job)),
registerImport: (job) =>
j("POST", "/import/" + encodeURIComponent(job) + "/register"),
};
// Streaming playground: POST /test/stream, calls onDelta(text) per content chunk,
+24 -2
View File
@@ -5,6 +5,7 @@ import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js";
import { renderLogin } from "./views/login.js";
import { renderDashboard } from "./views/dashboard.js";
import { renderModels } from "./views/models.js";
import { renderImport } from "./views/import.js";
import { renderRouting } from "./views/routing.js";
import { renderProtocols } from "./views/protocols.js";
import { renderKeys } from "./views/keys.js";
@@ -14,6 +15,7 @@ import { renderSettings } from "./views/settings.js";
const ROUTES = [
{ id: "dashboard", icon: "▤", view: renderDashboard },
{ id: "models", icon: "▦", view: renderModels },
{ id: "import", icon: "⬇", view: renderImport },
{ id: "routing", icon: "⇄", view: renderRouting },
{ id: "protocols", icon: "⇆", view: renderProtocols },
{ id: "keys", icon: "🔑", view: renderKeys },
@@ -23,6 +25,9 @@ const ROUTES = [
export const appState = { meta: null, health: null, version: "" };
// allaigate site — where router keys are issued and topped up
export const SITE_URL = "https://api.allaigate.com/";
function currentRoute() {
const id = location.hash.replace(/^#\/?/, "") || "dashboard";
return ROUTES.find((r) => r.id === id) || ROUTES[0];
@@ -53,6 +58,13 @@ function sidebar(activeId) {
)
),
h("div", { class: "nav-spacer" }),
h(
"a",
{ class: "nav-item", href: SITE_URL, target: "_blank", rel: "noopener" },
h("span", { class: "ico" }, "◈"),
t("nav.billing"),
h("span", { style: "margin-left:auto;opacity:.55" }, "↗")
),
h("div", { class: "nav-foot" }, t("nav.foot", { v: appState.version || "—" }))
);
}
@@ -61,6 +73,16 @@ function statusPills() {
const h_ = appState.health;
const gwOk = !!appState.meta;
const routerOk = h_?.router?.reachable;
// reachable but no key resolves → warn: routing silently degrades to default
const keyMissing = h_?.router?.key_source === "missing";
const routerDot = h_ ? (routerOk ? (keyMissing ? "warn" : "ok") : "bad") : "warn";
const routerText = h_
? routerOk
? keyMissing
? t("status.noKey")
: t("status.online")
: t("status.offline")
: t("status.checking");
return h(
"div",
{ class: "flex" },
@@ -73,8 +95,8 @@ function statusPills() {
h(
"span",
{ class: "pill", title: h_?.router?.url || "" },
h("span", { class: "dot " + (h_ ? (routerOk ? "ok" : "bad") : "warn") }),
t("status.router") + ": " + (h_ ? (routerOk ? t("status.online") : t("status.offline")) : t("status.checking"))
h("span", { class: "dot " + routerDot }),
t("status.router") + ": " + routerText
)
);
}
+15
View File
@@ -33,6 +33,7 @@ export default {
"nav.playground": "Playground",
"nav.settings": "Einstellungen",
"nav.foot": "Gateway · v{v}",
"nav.billing": "Router-Key & Abrechnung",
// topbar / status
"status.router": "Router",
@@ -40,6 +41,7 @@ export default {
"status.online": "online",
"status.offline": "offline",
"status.checking": "Prüfe…",
"status.noKey": "kein Key",
"theme.toggle": "Design",
"logout": "Abmelden",
@@ -88,6 +90,7 @@ export default {
"dash.health.title": "Status",
"dash.health.reachable": "erreichbar",
"dash.health.unreachable": "nicht erreichbar",
"dash.health.getKey": "Router-Key holen",
// models
"models.title": "Modelle",
@@ -225,6 +228,18 @@ export default {
"settings.router.timeout": "Timeout (ms)",
"settings.router.verifyTls": "TLS-Zertifikat prüfen",
"settings.router.taxonomy": "Taxonomie-ID",
"settings.router.getKey": "Router-Key holen oder aufladen:",
"settings.router.secret": "Router-Key (Wert)",
"settings.router.test": "Router testen",
"router.probe.ok": "Router OK ({ms} ms)",
"router.probe.noKey": "Router-Key ist nicht gesetzt",
"router.probe.auth": "Key abgelehnt — ungültig oder widerrufen",
"router.probe.payment": "Zahlung erforderlich — Tarif abgelaufen oder Kontingent erschöpft",
"router.probe.quota": "Ratenlimit überschritten",
"router.probe.timeout": "Router-Zeitüberschreitung",
"router.probe.unreachable": "Router nicht erreichbar",
"router.probe.error": "Router-Fehler",
"router.usage": "Verbrauch",
"settings.route": "Routing-Eingabe",
"settings.route.strategy": "Textstrategie",
"settings.route.maxChars": "Max. Zeichen",
+15
View File
@@ -33,6 +33,7 @@ export default {
"nav.playground": "Playground",
"nav.settings": "Configuración",
"nav.foot": "Gateway · v{v}",
"nav.billing": "Clave del router y pagos",
// topbar / status
"status.router": "Router",
@@ -40,6 +41,7 @@ export default {
"status.online": "en línea",
"status.offline": "sin conexión",
"status.checking": "verificando…",
"status.noKey": "sin clave",
"theme.toggle": "Tema",
"logout": "Cerrar sesión",
@@ -88,6 +90,7 @@ export default {
"dash.health.title": "Estado",
"dash.health.reachable": "accesible",
"dash.health.unreachable": "inaccesible",
"dash.health.getKey": "Obtener una clave del router",
// models
"models.title": "Modelos",
@@ -225,6 +228,18 @@ export default {
"settings.router.timeout": "Tiempo de espera (ms)",
"settings.router.verifyTls": "Verificar certificado TLS",
"settings.router.taxonomy": "ID de taxonomía",
"settings.router.getKey": "Obtén o recarga una clave del router en",
"settings.router.secret": "Clave del router (valor)",
"settings.router.test": "Probar router",
"router.probe.ok": "Router OK ({ms} ms)",
"router.probe.noKey": "La clave del router no está configurada",
"router.probe.auth": "Clave rechazada — inválida o revocada",
"router.probe.payment": "Pago requerido — plan vencido o cuota agotada",
"router.probe.quota": "Límite de tasa excedido",
"router.probe.timeout": "Tiempo de espera del router agotado",
"router.probe.unreachable": "Router inaccesible",
"router.probe.error": "Error del router",
"router.usage": "Consumo",
"settings.route": "Entrada de enrutamiento",
"settings.route.strategy": "Estrategia de texto",
"settings.route.maxChars": "Máx. caracteres",
+15
View File
@@ -33,6 +33,7 @@ export default {
"nav.playground": "Playground",
"nav.settings": "Paramètres",
"nav.foot": "Gateway · v{v}",
"nav.billing": "Clé routeur & facturation",
// topbar / status
"status.router": "Routeur",
@@ -40,6 +41,7 @@ export default {
"status.online": "en ligne",
"status.offline": "hors ligne",
"status.checking": "vérification…",
"status.noKey": "pas de clé",
"theme.toggle": "Thème",
"logout": "Déconnexion",
@@ -88,6 +90,7 @@ export default {
"dash.health.title": "Santé",
"dash.health.reachable": "accessible",
"dash.health.unreachable": "inaccessible",
"dash.health.getKey": "Obtenir une clé routeur",
// models
"models.title": "Modèles",
@@ -225,6 +228,18 @@ export default {
"settings.router.timeout": "Délai d'expiration (ms)",
"settings.router.verifyTls": "Vérifier le certificat TLS",
"settings.router.taxonomy": "ID de taxonomie",
"settings.router.getKey": "Obtenir ou recharger une clé routeur sur",
"settings.router.secret": "Clé routeur (valeur)",
"settings.router.test": "Tester le routeur",
"router.probe.ok": "Routeur OK ({ms} ms)",
"router.probe.noKey": "La clé routeur n'est pas définie",
"router.probe.auth": "Clé rejetée — invalide ou révoquée",
"router.probe.payment": "Paiement requis — forfait expiré ou quota épuisé",
"router.probe.quota": "Limite de débit dépassée",
"router.probe.timeout": "Délai du routeur dépassé",
"router.probe.unreachable": "Routeur inaccessible",
"router.probe.error": "Erreur du routeur",
"router.usage": "Consommation",
"settings.route": "Entrée de routage",
"settings.route.strategy": "Stratégie de texte",
"settings.route.maxChars": "Caractères max",
+15
View File
@@ -33,6 +33,7 @@ export default {
"nav.playground": "Oyun alanı",
"nav.settings": "Ayarlar",
"nav.foot": "Gateway · v{v}",
"nav.billing": "Router anahtarı ve ödeme",
// topbar / status
"status.router": "Yönlendirici",
@@ -40,6 +41,7 @@ export default {
"status.online": "çevrimiçi",
"status.offline": "çevrimdışı",
"status.checking": "kontrol ediliyor…",
"status.noKey": "anahtar yok",
"theme.toggle": "Tema",
"logout": "Çıkış yap",
@@ -88,6 +90,7 @@ export default {
"dash.health.title": "Sağlık",
"dash.health.reachable": "erişilebilir",
"dash.health.unreachable": "erişilemiyor",
"dash.health.getKey": "Router anahtarı al",
// models
"models.title": "Modeller",
@@ -225,6 +228,18 @@ export default {
"settings.router.timeout": "Zaman aşımı (ms)",
"settings.router.verifyTls": "TLS sertifikasını doğrula",
"settings.router.taxonomy": "Taksonomi id",
"settings.router.getKey": "Router anahtarı almak veya bakiye yüklemek için:",
"settings.router.secret": "Router anahtarı (değer)",
"settings.router.test": "Router'ı test et",
"router.probe.ok": "Router çalışıyor ({ms} ms)",
"router.probe.noKey": "Router anahtarı ayarlanmadı",
"router.probe.auth": "Anahtar reddedildi — geçersiz veya iptal edilmiş",
"router.probe.payment": "Ödeme gerekli — plan süresi doldu veya kota tükendi",
"router.probe.quota": "Hız sınırı aşıldı",
"router.probe.timeout": "Router zaman aşımına uğradı",
"router.probe.unreachable": "Router erişilemiyor",
"router.probe.error": "Router hatası",
"router.usage": "Kullanım",
"settings.route": "Yönlendirme girişi",
"settings.route.strategy": "Metin stratejisi",
"settings.route.maxChars": "Maks. karakter",
+15
View File
@@ -33,6 +33,7 @@ export default {
"nav.playground": "沙盒",
"nav.settings": "设置",
"nav.foot": "网关 · v{v}",
"nav.billing": "路由器密钥与充值",
// topbar / status
"status.router": "路由器",
@@ -40,6 +41,7 @@ export default {
"status.online": "在线",
"status.offline": "离线",
"status.checking": "检测中…",
"status.noKey": "无密钥",
"theme.toggle": "主题",
"logout": "退出",
@@ -88,6 +90,7 @@ export default {
"dash.health.title": "健康状态",
"dash.health.reachable": "可达",
"dash.health.unreachable": "不可达",
"dash.health.getKey": "获取路由器密钥",
// models
"models.title": "模型",
@@ -225,6 +228,18 @@ export default {
"settings.router.timeout": "超时(ms)",
"settings.router.verifyTls": "验证 TLS 证书",
"settings.router.taxonomy": "分类体系 ID",
"settings.router.getKey": "获取或充值路由器密钥:",
"settings.router.secret": "路由器密钥(值)",
"settings.router.test": "测试路由器",
"router.probe.ok": "路由器正常({ms} ms)",
"router.probe.noKey": "未设置路由器密钥",
"router.probe.auth": "密钥被拒绝——无效或已吊销",
"router.probe.payment": "需要付款——套餐到期或配额用尽",
"router.probe.quota": "超出速率限制",
"router.probe.timeout": "路由器响应超时",
"router.probe.unreachable": "路由器不可达",
"router.probe.error": "路由器错误",
"router.usage": "用量",
"settings.route": "路由输入",
"settings.route.strategy": "文本策略",
"settings.route.maxChars": "最大字符数",
+27 -3
View File
@@ -3,6 +3,7 @@ import { h, mount, money, num, ms, pct, timeAgo } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
import { lineChart, barList } from "../charts.js";
import { SITE_URL } from "../app.js";
const RANGES = ["1h", "24h", "7d"];
const GROUPS = ["model", "tier", "label", "account"];
@@ -186,7 +187,19 @@ function recentTable(rows) {
);
}
function keySourceLabel(src) {
if (src === "store") return t("models.form.secretStored");
if (src === "env") return t("models.form.secretEnv");
return t("models.form.secretMissing");
}
function healthCard(hh) {
const r = hh.router || {};
const keySrc = r.key_source || "none";
const err = r.last_error;
// problems fixed on the site (get / pay for a key) get the top-up link
const needsSite =
!r.reachable || keySrc === "missing" || (err && ["auth", "payment", "quota"].includes(err.kind));
return h(
"div",
{ class: "card" },
@@ -196,9 +209,20 @@ function healthCard(hh) {
{ class: "flex wrap", style: "margin-bottom:12px" },
h(
"span",
{ class: "badge " + (hh.router?.reachable ? "ok" : "bad"), title: hh.router?.url || "" },
t("status.router") + ": " + (hh.router?.reachable ? t("dash.health.reachable") : t("dash.health.unreachable"))
)
{ class: "badge " + (r.reachable ? "ok" : "bad"), title: r.url || "" },
t("status.router") + ": " + (r.reachable ? t("dash.health.reachable") : t("dash.health.unreachable"))
),
keySrc !== "none"
? h(
"span",
{ class: "badge " + keyBadge(keySrc), title: r.key_env || "" },
t("models.col.key") + ": " + keySourceLabel(keySrc)
)
: null,
err ? h("span", { class: "badge bad" }, t("router.probe." + (err.kind || "error"))) : null,
needsSite
? h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗")
: null
),
h(
"div",
+332
View File
@@ -0,0 +1,332 @@
// Import a HuggingFace model → convert to a local .cmf (our format) with a
// live progress view. Search HF, pick quantization + advanced params, watch
// the conversion (multiple in parallel, resumable across tab switches), then
// register the result as a local model.
import { h, mount, toast } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
const QUANTS = [
["Q8_2F", "import.q.q8_2f"],
["Q8_ROW", "import.q.q8_row"],
["Q4_BLOCK", "import.q.q4"],
["VBIT", "import.q.vbit"],
["F16", "import.q.f16"],
["F32", "import.q.f32"],
];
function field(label, control, hint) {
return h("label", { class: "field" }, h("span", {}, label), control,
hint ? h("div", { class: "hint" }, hint) : null);
}
function opt(v, label, sel) {
return h("option", { value: v, selected: sel ? true : null }, label || v);
}
function fmtNum(n) {
if (n == null) return "—";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(n);
}
function fmtBytes(n) {
if (!n) return "";
const g = n / 1e9;
return g >= 1 ? g.toFixed(2) + " GB" : (n / 1e6).toFixed(0) + " MB";
}
// state → localized label (falls back to the raw state)
function stateLabel(s) {
const k = "import.state." + s;
const v = t(k);
return v === k ? s : v;
}
export function renderImport() {
let selected = null;
let lastModels = []; // cache of the last search, so re-highlighting needs no refetch
let jobsTimer = null;
const searchIn = h("input", {
class: "hf-search",
placeholder: t("import.searchPlaceholder"),
autofocus: true,
});
const results = h("div", { class: "hf-grid" });
const formHost = h("div", {});
const jobsHost = h("div", {});
// ── live HF search (debounced) ──
let debounce = null;
function renderCards(models) {
if (!models.length) {
mount(results, h("div", { class: "muted pad" }, t("import.noResults")));
return;
}
mount(results, ...models.map(modelCard));
}
async function doSearch() {
const q = searchIn.value.trim();
mount(results, h("div", { class: "muted pad" }, t("import.searching")));
try {
const r = await api.hfSearch(q, 24);
lastModels = r.models || [];
renderCards(lastModels);
} catch (e) {
mount(results, h("div", { class: "err pad" }, "HF: " + e.message));
}
}
searchIn.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(doSearch, 300);
});
function modelCard(m) {
const card = h(
"div",
{ class: "hf-card" + (selected && selected.id === m.id ? " sel" : "") },
h("div", { class: "hf-id" }, m.id),
h(
"div",
{ class: "hf-meta" },
m.pipeline_tag ? h("span", { class: "badge" }, m.pipeline_tag) : null,
h("span", { class: "muted" }, "▼ " + fmtNum(m.downloads)),
h("span", { class: "muted" }, "♥ " + fmtNum(m.likes)),
(m.tags || []).some((x) => x === "gated") ? h("span", { class: "badge bad" }, "gated") : null
)
);
card.addEventListener("click", () => selectModel(m));
return card;
}
function selectModel(m) {
selected = m;
renderCards(lastModels); // re-highlight from cache — no refetch, no flash
renderForm();
formHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ── conversion form ──
function renderForm() {
if (!selected) {
mount(formHost);
return;
}
const nameIn = h("input", { value: (selected.id.split("/").pop() || "model").toLowerCase() });
const quantSel = h("select", {}, ...QUANTS.map(([v], i) => opt(v, v, i === 0)));
const quantHint = h("div", { class: "hint" }, t("import.q.q8_2f"));
quantSel.addEventListener("change", () => {
const k = QUANTS.find(([v]) => v === quantSel.value);
quantHint.textContent = k ? t(k[1]) : "";
});
// advanced (collapsed)
const linearSel = h("select", {}, opt("", t("import.adv.auto")), opt("gated_delta_net", "gated_delta_net"), opt("vmf_phase", "vmf_phase"));
const nphaseIn = h("input", { type: "number", min: "1", placeholder: "16" });
const vbitSel = h("select", {}, opt("", t("import.adv.auto")), opt("log2", "log2"), opt("cubic", "cubic"));
const meanBitsIn = h("input", { type: "number", step: "0.1", placeholder: "5.0" });
const shardIn = h("input", { type: "number", step: "0.5", placeholder: t("import.adv.noShard") });
const skipMtp = h("input", { type: "checkbox" });
const advBody = h(
"div",
{ class: "adv-body", style: "display:none" },
h("div", { class: "row" },
field(t("import.adv.linearCore"), linearSel, t("import.adv.linearHint")),
field(t("import.adv.nphase"), nphaseIn)),
h("div", { class: "row" },
field(t("import.adv.vbitShape"), vbitSel),
field(t("import.adv.meanBits"), meanBitsIn)),
h("div", { class: "row" },
field(t("import.adv.shard"), shardIn),
field("MTP", h("label", { class: "check" }, skipMtp, t("import.adv.skipMtp")))),
);
const advToggle = h("button", { class: "btn ghost sm" }, "⚙ " + t("import.adv.title"));
advToggle.addEventListener("click", () => {
advBody.style.display = advBody.style.display === "none" ? "block" : "none";
});
const goBtn = h("button", { class: "btn primary" }, "⬇ " + t("import.convert"));
goBtn.addEventListener("click", async () => {
const params = {
repo: selected.id,
quant: quantSel.value,
name: nameIn.value.trim(),
skip_mtp: skipMtp.checked,
};
if (linearSel.value) params.linear_core = linearSel.value;
if (nphaseIn.value) params.nphase = parseInt(nphaseIn.value);
if (vbitSel.value) params.vbit_shape = vbitSel.value;
if (meanBitsIn.value) params.mean_bits = parseFloat(meanBitsIn.value);
if (shardIn.value) params.shard_max_gb = parseFloat(shardIn.value);
goBtn.disabled = true;
try {
await api.startImport(params);
toast(t("import.started"), "ok");
await refreshJobs(); // picks up the new job + (re)starts polling
jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (e) {
toast(e.message, "err");
}
goBtn.disabled = false;
});
mount(
formHost,
h("div", { class: "card import-form" },
h("div", { class: "card-title" }, t("import.configure") + ": " + selected.id),
h("div", { class: "row" },
field(t("import.outName"), nameIn, t("import.outNameHint")),
field(t("import.quant"), quantSel)),
quantHint,
h("div", { class: "adv" }, advToggle, advBody),
h("div", { class: "actions" }, goBtn),
)
);
}
// ── jobs panel: all conversions, live, resumable, cancellable ──
function ensurePolling() {
if (!jobsTimer) {
jobsTimer = setInterval(() => {
// stop once this view instance was replaced (navigated away)
if (!root.isConnected) {
stopPolling();
return;
}
refreshJobs();
}, 1500);
}
}
function stopPolling() {
if (jobsTimer) {
clearInterval(jobsTimer);
jobsTimer = null;
}
}
// Load + render all jobs. Runs on mount (before `root` is attached — so no
// isConnected guard here) and on each poll tick.
async function refreshJobs() {
let jobs = [];
try {
jobs = (await api.listImports()).jobs || [];
} catch {
return;
}
jobs = jobs.filter((j) => j.state !== "cancelled"); // hide user-cancelled jobs
renderJobs(jobs);
if (jobs.some((j) => j.state === "running")) ensurePolling();
else stopPolling();
}
function jobCard(j) {
const running = j.state === "running";
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
// det = solid bar at a known %, run = indeterminate slider, ok/err = terminal.
let bar;
if (j.state === "done") {
bar = h("div", { class: "pbar-fill ok", style: "width:100%" });
} else if (running && pct != null) {
bar = h("div", { class: "pbar-fill det", style: `width:${pct}%` });
} else if (running) {
bar = h("div", { class: "pbar-fill run" }); // CSS drives width + animation
} else {
bar = h("div", { class: "pbar-fill err", style: "width:100%" }); // error / cancelled
}
const barWrap = h("div", { class: "pbar" }, bar);
const badge = h(
"span",
{ class: "badge " + (j.state === "done" ? "" : running ? "" : "bad") },
stateLabel(j.state) +
(running && pct != null ? " · " + pct + "%" : "") +
(j.size_bytes ? " · " + fmtBytes(j.size_bytes) : "")
);
const phase = h("div", { class: "muted small" }, j.phase || "");
const logEl = h("pre", { class: "conv-log" }, (j.log || []).slice(-10).join("\n"));
const actions = [];
if (running) {
const c = h("button", { class: "btn ghost sm" }, "✕ " + t("import.cancel"));
c.addEventListener("click", async () => {
c.disabled = true;
try {
await api.cancelImport(j.id);
toast(t("import.cancelling"), "warn");
refreshJobs();
} catch (e) {
toast(e.message, "err");
c.disabled = false;
}
});
actions.push(c);
}
if (j.state === "done") {
const r = h("button", { class: "btn primary sm" }, "✓ " + t("import.register"));
r.addEventListener("click", async () => {
r.disabled = true;
try {
const rr = await api.registerImport(j.id);
toast(t("import.registered", { id: rr.model_id }), "ok");
} catch (e) {
toast(e.message, "err");
r.disabled = false;
}
});
actions.push(r);
}
if (!running) {
// delete the job + its converted .cmf file(s) from disk
const d = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("import.delete"));
d.addEventListener("click", async () => {
if (!window.confirm(t("import.deleteConfirm"))) return;
d.disabled = true;
try {
await api.deleteImport(j.id);
toast(t("import.deleted"), "ok");
refreshJobs();
} catch (e) {
toast(e.message, "err");
d.disabled = false;
}
});
actions.push(d);
}
return h(
"div",
{ class: "job" },
h("div", { class: "job-head" }, h("b", {}, j.repo || j.id), badge),
barWrap,
phase,
logEl,
actions.length ? h("div", { class: "actions" }, ...actions) : null
);
}
function renderJobs(jobs) {
if (!jobs.length) {
mount(jobsHost);
return;
}
mount(
jobsHost,
h(
"div",
{ class: "card" },
h("div", { class: "card-title" }, t("import.jobs")),
...jobs.map(jobCard)
)
);
}
const root = h(
"div",
{},
h("div", { class: "view-head" },
h("h2", {}, t("nav.import")),
h("div", { class: "muted" }, t("import.subtitle"))),
jobsHost, // progress panel on top, above the search
h("div", { class: "card" }, searchIn, results),
formHost
);
doSearch(); // initial trending list (mounts into `results`)
refreshJobs(); // resume: show any running/recent conversions immediately
return root;
}
+7 -1
View File
@@ -2,6 +2,7 @@
import { h } from "../ui.js";
import { t } from "../i18n.js";
import { setToken } from "../api.js";
import { SITE_URL } from "../app.js";
export function renderLogin(onOk) {
const input = h("input", {
@@ -34,7 +35,12 @@ export function renderLogin(onOk) {
h("p", { class: "sub" }, t("login.subtitle")),
h("label", { class: "field" }, h("span", {}, t("login.token")), input),
h("button", { class: "btn primary", style: "width:100%", onClick: submit }, t("login.submit")),
h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint"))
h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint")),
h(
"p",
{ class: "hint", style: "margin-top:8px" },
h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("nav.billing") + " · api.allaigate.com ↗")
)
)
);
}