mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-05-20 09:02:38 +00:00
465 lines
14 KiB
JavaScript
465 lines
14 KiB
JavaScript
const state = {
|
|
config: null,
|
|
status: null,
|
|
fields: new Map(),
|
|
localStatus: new Map(),
|
|
modelOptions: [],
|
|
};
|
|
|
|
const MASKED_SECRET = "********";
|
|
|
|
const byId = (id) => document.getElementById(id);
|
|
|
|
function sourceLabel(source) {
|
|
const labels = {
|
|
default: "default",
|
|
template: "template",
|
|
repo_env: "repo .env",
|
|
managed_env: "managed",
|
|
explicit_env_file: "FCC_ENV_FILE",
|
|
process: "process env",
|
|
};
|
|
return labels[source] || source;
|
|
}
|
|
|
|
function providerName(providerId) {
|
|
const names = {
|
|
nvidia_nim: "NVIDIA NIM",
|
|
open_router: "OpenRouter",
|
|
deepseek: "DeepSeek",
|
|
lmstudio: "LM Studio",
|
|
llamacpp: "llama.cpp",
|
|
ollama: "Ollama",
|
|
kimi: "Kimi",
|
|
wafer: "Wafer",
|
|
};
|
|
if (names[providerId]) return names[providerId];
|
|
return providerId
|
|
.split("_")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function statusClass(status) {
|
|
if (["configured", "reachable", "running"].includes(status)) return "ok";
|
|
if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn";
|
|
if (["offline", "error"].includes(status)) return "error";
|
|
return "neutral";
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {
|
|
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
|
...options,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function load() {
|
|
showMessage("Loading admin config");
|
|
const [config, status] = await Promise.all([
|
|
api("/admin/api/config"),
|
|
api("/admin/api/status"),
|
|
]);
|
|
state.config = config;
|
|
state.status = status;
|
|
state.fields = new Map(config.fields.map((field) => [field.key, field]));
|
|
updateHeader(status);
|
|
renderNav(config.sections);
|
|
renderProviders(config.provider_status);
|
|
renderSections(config.sections, config.fields);
|
|
byId("configPath").textContent = config.paths.managed;
|
|
await validate(false);
|
|
await refreshLocalStatus();
|
|
updateDirtyState();
|
|
showMessage("");
|
|
}
|
|
|
|
function updateHeader(status) {
|
|
const serverStatus = byId("serverStatus");
|
|
serverStatus.textContent = "Running";
|
|
serverStatus.className = "status-pill ok";
|
|
byId("modelBadge").textContent = status.model || "";
|
|
}
|
|
|
|
function renderNav(sections) {
|
|
const nav = byId("sectionNav");
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
sections.forEach((section, index) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `nav-link${index === 0 ? " active" : ""}`;
|
|
button.textContent = section.label;
|
|
button.dataset.section = section.id;
|
|
button.style.animation = `slide-up 0.4s ease-out both ${index * 0.05}s`;
|
|
fragment.appendChild(button);
|
|
});
|
|
|
|
nav.innerHTML = "";
|
|
nav.appendChild(fragment);
|
|
|
|
// Use event delegation for navigation (⚡ Bolt Optimization: 41-50)
|
|
nav.addEventListener("click", (e) => {
|
|
const btn = e.target.closest(".nav-link");
|
|
if (!btn) return;
|
|
|
|
document.querySelectorAll(".nav-link").forEach((link) => {
|
|
link.classList.remove("active");
|
|
});
|
|
btn.classList.add("active");
|
|
|
|
const sectionId = btn.dataset.section;
|
|
byId(`section-${sectionId}`).scrollIntoView({ behavior: "smooth" });
|
|
});
|
|
}
|
|
|
|
function renderProviders(providerStatus) {
|
|
const grid = byId("providerGrid");
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
providerStatus.forEach((provider, index) => {
|
|
const card = document.createElement("article");
|
|
card.className = "provider-card";
|
|
card.dataset.provider = provider.provider_id;
|
|
card.style.animationDelay = `${index * 0.1}s`;
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "provider-title";
|
|
const titleStrong = document.createElement("strong");
|
|
titleStrong.textContent = providerName(provider.provider_id);
|
|
title.appendChild(titleStrong);
|
|
|
|
const pill = document.createElement("span");
|
|
pill.className = `status-pill ${statusClass(provider.status)}`;
|
|
pill.textContent = provider.label;
|
|
title.appendChild(pill);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "provider-meta";
|
|
meta.textContent =
|
|
provider.kind === "local"
|
|
? provider.base_url || "No local URL configured"
|
|
: provider.credential_env;
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "test-button";
|
|
button.textContent = provider.kind === "local" ? "Test" : "Refresh models";
|
|
button.addEventListener("click", () => testProvider(provider.provider_id, button));
|
|
|
|
card.append(title, meta, button);
|
|
fragment.appendChild(card);
|
|
});
|
|
|
|
grid.innerHTML = "";
|
|
grid.appendChild(fragment);
|
|
}
|
|
|
|
function updateProviderCard(providerId, status, label, metaText) {
|
|
const card = document.querySelector(`[data-provider="${providerId}"]`);
|
|
if (!card) return;
|
|
const pill = card.querySelector(".status-pill");
|
|
pill.className = `status-pill ${statusClass(status)}`;
|
|
pill.textContent = label;
|
|
if (metaText) {
|
|
card.querySelector(".provider-meta").textContent = metaText;
|
|
}
|
|
}
|
|
|
|
function renderSections(sections, fields) {
|
|
const container = byId("formSections");
|
|
container.innerHTML = "";
|
|
const bySection = new Map();
|
|
sections.forEach((section) => bySection.set(section.id, []));
|
|
fields.forEach((field) => {
|
|
if (!bySection.has(field.section)) bySection.set(field.section, []);
|
|
bySection.get(field.section).push(field);
|
|
});
|
|
|
|
sections.forEach((section) => {
|
|
const sectionEl = document.createElement("section");
|
|
sectionEl.className = "settings-section";
|
|
sectionEl.id = `section-${section.id}`;
|
|
|
|
const heading = document.createElement("div");
|
|
heading.className = "section-heading";
|
|
const headingWrap = document.createElement("div");
|
|
const h3 = document.createElement("h3");
|
|
h3.textContent = section.label;
|
|
const p = document.createElement("p");
|
|
p.textContent = section.description;
|
|
headingWrap.append(h3, p);
|
|
heading.appendChild(headingWrap);
|
|
sectionEl.appendChild(heading);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "field-grid";
|
|
bySection.get(section.id).forEach((field) => {
|
|
grid.appendChild(renderField(field));
|
|
});
|
|
sectionEl.appendChild(grid);
|
|
|
|
if (bySection.get(section.id).some((field) => field.advanced)) {
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "ghost-button advanced-toggle";
|
|
toggle.textContent = "Show advanced";
|
|
toggle.addEventListener("click", () => {
|
|
const showing = sectionEl.classList.toggle("show-advanced");
|
|
toggle.textContent = showing ? "Hide advanced" : "Show advanced";
|
|
});
|
|
sectionEl.appendChild(toggle);
|
|
}
|
|
|
|
container.appendChild(sectionEl);
|
|
});
|
|
}
|
|
|
|
function renderField(field) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = `field${field.advanced ? " advanced-field" : ""}`;
|
|
wrapper.dataset.key = field.key;
|
|
|
|
const label = document.createElement("label");
|
|
label.htmlFor = `field-${field.key}`;
|
|
const labelText = document.createElement("span");
|
|
labelText.textContent = field.label;
|
|
const labelSource = document.createElement("span");
|
|
labelSource.className = `field-source${field.locked ? " locked" : ""}`;
|
|
labelSource.textContent = sourceLabel(field.source);
|
|
label.append(labelText, labelSource);
|
|
|
|
const input = inputForField(field);
|
|
input.id = `field-${field.key}`;
|
|
input.dataset.key = field.key;
|
|
input.dataset.original = field.value || "";
|
|
input.dataset.secret = field.secret ? "true" : "false";
|
|
input.dataset.configured = field.configured ? "true" : "false";
|
|
input.disabled = field.locked;
|
|
input.addEventListener("input", updateDirtyState);
|
|
input.addEventListener("change", updateDirtyState);
|
|
|
|
wrapper.append(label, input);
|
|
if (field.description) {
|
|
const description = document.createElement("div");
|
|
description.className = "field-description";
|
|
description.textContent = field.description;
|
|
wrapper.appendChild(description);
|
|
}
|
|
return wrapper;
|
|
}
|
|
|
|
function inputForField(field) {
|
|
if (field.type === "boolean") {
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = String(field.value).toLowerCase() === "true";
|
|
input.dataset.original = input.checked ? "true" : "false";
|
|
return input;
|
|
}
|
|
|
|
if (field.type === "tri_boolean") {
|
|
const select = document.createElement("select");
|
|
[
|
|
["", "Inherit"],
|
|
["true", "Enabled"],
|
|
["false", "Disabled"],
|
|
].forEach(([value, label]) => select.appendChild(option(value, label)));
|
|
select.value = field.value || "";
|
|
return select;
|
|
}
|
|
|
|
if (field.type === "select") {
|
|
const select = document.createElement("select");
|
|
field.options.forEach((value) => select.appendChild(option(value, value)));
|
|
select.value = field.value || field.options[0] || "";
|
|
return select;
|
|
}
|
|
|
|
if (field.type === "textarea") {
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = field.value || "";
|
|
return textarea;
|
|
}
|
|
|
|
const input = document.createElement("input");
|
|
input.type = field.type === "number" ? "number" : "text";
|
|
if (field.type === "secret") {
|
|
input.type = "password";
|
|
input.placeholder = field.configured
|
|
? "Configured - enter a new value to replace"
|
|
: "Not configured";
|
|
input.value = "";
|
|
input.autocomplete = "off";
|
|
} else {
|
|
input.value = field.value || "";
|
|
}
|
|
if (field.key.startsWith("MODEL")) {
|
|
input.setAttribute("list", "model-options");
|
|
}
|
|
return input;
|
|
}
|
|
|
|
function option(value, label) {
|
|
const optionEl = document.createElement("option");
|
|
optionEl.value = value;
|
|
optionEl.textContent = label;
|
|
return optionEl;
|
|
}
|
|
|
|
function readFieldValue(input) {
|
|
if (input.type === "checkbox") return input.checked ? "true" : "false";
|
|
if (input.dataset.secret === "true" && input.dataset.configured === "true") {
|
|
return input.value ? input.value : MASKED_SECRET;
|
|
}
|
|
return input.value;
|
|
}
|
|
|
|
function changedValues() {
|
|
const values = {};
|
|
document.querySelectorAll("[data-key]").forEach((input) => {
|
|
if (input.disabled || !input.matches("input, select, textarea")) return;
|
|
const value = readFieldValue(input);
|
|
if (value !== input.dataset.original) {
|
|
values[input.dataset.key] = value;
|
|
}
|
|
});
|
|
return values;
|
|
}
|
|
|
|
function updateDirtyState() {
|
|
const count = Object.keys(changedValues()).length;
|
|
byId("dirtyState").textContent =
|
|
count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`;
|
|
byId("applyButton").disabled = count === 0;
|
|
}
|
|
|
|
async function validate(showResult = true) {
|
|
const result = await api("/admin/api/config/validate", {
|
|
method: "POST",
|
|
body: JSON.stringify({ values: changedValues() }),
|
|
});
|
|
byId("envPreview").textContent = result.env_preview || "";
|
|
if (showResult) {
|
|
showValidationResult(result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function showValidationResult(result) {
|
|
if (result.valid) {
|
|
showMessage("Config shape is valid", "ok");
|
|
} else {
|
|
showMessage(result.errors.join("; "), "error");
|
|
}
|
|
}
|
|
|
|
async function apply() {
|
|
const result = await api("/admin/api/config/apply", {
|
|
method: "POST",
|
|
body: JSON.stringify({ values: changedValues() }),
|
|
});
|
|
byId("envPreview").textContent = result.env_preview || "";
|
|
if (!result.applied) {
|
|
showValidationResult(result);
|
|
return;
|
|
}
|
|
const restart = result.restart || {};
|
|
if (restart.required && restart.automatic) {
|
|
showMessage("Applied. Restarting server...", "ok");
|
|
byId("applyButton").disabled = true;
|
|
setTimeout(() => {
|
|
window.location.href = restart.admin_url || "/admin";
|
|
}, 1600);
|
|
return;
|
|
}
|
|
const pending = restart.required ? restart.fields || [] : result.pending_fields || [];
|
|
await load();
|
|
showMessage(
|
|
pending.length
|
|
? `Applied. Restart fcc-server to use: ${pending.join(", ")}`
|
|
: "Applied",
|
|
"ok",
|
|
);
|
|
}
|
|
|
|
async function refreshLocalStatus() {
|
|
const result = await api("/admin/api/providers/local-status");
|
|
result.providers.forEach((provider) => {
|
|
state.localStatus.set(provider.provider_id, provider);
|
|
const meta = provider.status_code
|
|
? `${provider.base_url} returned HTTP ${provider.status_code}`
|
|
: provider.base_url;
|
|
updateProviderCard(provider.provider_id, provider.status, provider.label, meta);
|
|
});
|
|
}
|
|
|
|
async function testProvider(providerId, button) {
|
|
const original = button.textContent;
|
|
button.disabled = true;
|
|
button.textContent = "Testing";
|
|
try {
|
|
const result = await api(`/admin/api/providers/${providerId}/test`, {
|
|
method: "POST",
|
|
body: "{}",
|
|
});
|
|
if (result.ok) {
|
|
updateProviderCard(
|
|
providerId,
|
|
"reachable",
|
|
`${result.models.length} models`,
|
|
result.models.slice(0, 3).join(", ") || "No models returned",
|
|
);
|
|
state.modelOptions = Array.from(
|
|
new Set([...state.modelOptions, ...result.models.map((model) => `${providerId}/${model}`)]),
|
|
).sort();
|
|
syncModelDatalist();
|
|
} else {
|
|
updateProviderCard(providerId, "offline", result.error_type, result.error_type);
|
|
}
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = original;
|
|
}
|
|
}
|
|
|
|
function syncModelDatalist() {
|
|
let datalist = byId("model-options");
|
|
if (!datalist) {
|
|
datalist = document.createElement("datalist");
|
|
datalist.id = "model-options";
|
|
document.body.appendChild(datalist);
|
|
}
|
|
datalist.innerHTML = "";
|
|
state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
|
|
}
|
|
|
|
function showMessage(message, kind = "") {
|
|
const area = byId("messageArea");
|
|
if (!message) {
|
|
area.style.opacity = "0";
|
|
setTimeout(() => {
|
|
area.textContent = "";
|
|
area.className = "message-area";
|
|
}, 300);
|
|
return;
|
|
}
|
|
area.textContent = message;
|
|
area.className = `message-area ${kind}`.trim();
|
|
area.style.opacity = "1";
|
|
area.style.animation = "slide-up 0.3s ease-out both";
|
|
}
|
|
|
|
byId("validateButton").addEventListener("click", () => validate(true));
|
|
byId("applyButton").addEventListener("click", apply);
|
|
byId("refreshLocal").addEventListener("click", refreshLocalStatus);
|
|
|
|
load().catch((error) => {
|
|
byId("serverStatus").textContent = "Error";
|
|
byId("serverStatus").className = "status-pill error";
|
|
showMessage(error.message, "error");
|
|
});
|