From de9e00c66d42b04f9396d7c0e2b7b03e57845c4d Mon Sep 17 00:00:00 2001 From: luulam Date: Mon, 17 Aug 2026 00:17:55 +0700 Subject: [PATCH] feat(settings): runtime log level + free provider enable/disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LOG_LEVEL env + runtime setLogLevel (dashboard Settings โ†’ Logging), applied immediately, persisted across restarts; WARN/ERROR quiet production INFO lines (โ–ถ POST / ๐Ÿ“Š DONE / [COMBO] / [CHAT]) - Allow toggling free/noAuth providers (gemini-cli, kilo, etc.) off via providerStrategies.enabled from Providers page and provider detail page - auth.js: honor disabled override before noAuth/connection branches - CompatibleModelsSection: parallel model testing --- .env.example | 4 + .gitignore | 3 + src/app/(dashboard)/dashboard/profile/page.js | 50 +++ .../providers/[id]/CompatibleModelsSection.js | 72 ++-- .../dashboard/providers/[id]/page.js | 326 +++++++++++++++++- .../(dashboard)/dashboard/providers/page.js | 62 +++- src/app/api/settings/route.js | 6 + src/lib/db/repos/settingsRepo.js | 1 + src/shared/services/initializeApp.js | 9 + src/sse/services/auth.js | 13 +- src/sse/utils/logger.js | 22 +- 11 files changed, 507 insertions(+), 61 deletions(-) diff --git a/.env.example b/.env.example index 6ed8f81e..e8ab827b 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ NODE_ENV=production API_KEY_SECRET=endpoint-proxy-api-key-secret MACHINE_ID_SALT=endpoint-proxy-salt ENABLE_REQUEST_LOGS=false +# Console verbosity: DEBUG | INFO | WARN | ERROR. Default INFO. In production set +# ERROR to only print important errors (hides โ–ถ POST / ๐Ÿ“Š DONE / [COMBO] / [CHAT]). +# Can also be changed at runtime from dashboard Settings โ†’ Logging. +# LOG_LEVEL=ERROR OBSERVABILITY_ENABLED=true AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false diff --git a/.gitignore b/.gitignore index 7a15c62c..56cea64b 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ graphify-out/* # CommandCode CLI local state (auth/taste/projects) .commandcode/ + +# Pi subagent run artifacts +.pi-subagents/ diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index e7251247..18727bcf 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -536,6 +536,21 @@ export default function ProfilePage() { } }; + const updateLogLevel = async (logLevel) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ logLevel }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, logLevel })); + } + } catch (err) { + console.error("Failed to update logLevel:", err); + } + }; + const updateShowOnlyComboModels = async (showOnlyComboModels) => { try { const res = await fetch("/api/settings", { @@ -1448,6 +1463,41 @@ export default function ProfilePage() { + {/* Logging Settings */} + +
+
+ + terminal + +
+

Logging

+
+
+
+

Log level

+

+ Controls how much the server prints to console. In production, + set to Error to only show + important errors, hiding the per-request INFO lines (โ–ถ POST, + ๐Ÿ“Š DONE, [COMBO], [CHAT]). Applied immediately, no restart + needed. +

+
+ +
+
+ {/* Account actions */}
- {testAllRunning && ( - - )}
{(testAllResults || testAllRunning) && ( @@ -225,15 +206,10 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider {testAllRunning - ? `Testing... ${(testAllResults?.passed || 0) + (testAllResults?.failed || 0)}/${allModels.length}` + ? `Testing... ${allModels.length} model${allModels.length > 1 ? "s" : ""} in parallel` : `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed` } - {testingModelId && testAllRunning && ( - - (current: {testingModelId}) - - )} {!testAllRunning && failedIds.length > 0 && ( )} + {selectedConnectionIds.length > 0 && ( + + )} + )} {disabledModelIds.length > 0 && ( + + + + + {/* Modals */} {providerId === "kiro" ? ( s.query); const registerSearch = useHeaderSearchStore((s) => s.register); @@ -148,15 +149,18 @@ export default function ProvidersPage() { useEffect(() => { const fetchData = async () => { try { - const [connectionsRes, nodesRes] = await Promise.all([ + const [connectionsRes, nodesRes, settingsRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/provider-nodes"), + fetch("/api/settings", { cache: "no-store" }), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; if (connectionsRes.ok) setConnections(connectionsData.connections || []); if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); + setProviderStrategies(settingsData.providerStrategies || {}); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -231,6 +235,25 @@ export default function ProvidersPage() { ); }; + // Toggle a free provider (noAuth or zero-connection) via providerStrategies.enabled. + const handleToggleNoAuthProvider = async (providerId, newActive) => { + try { + const res = await fetch("/api/settings", { cache: "no-store" }); + const data = res.ok ? await res.json() : {}; + const allStrategies = { ...(data.providerStrategies || {}) }; + const override = { ...(allStrategies[providerId] || {}), enabled: newActive }; + allStrategies[providerId] = override; + setProviderStrategies(allStrategies); + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerStrategies: allStrategies }), + }); + } catch (error) { + console.log("Error toggling noAuth provider:", error); + } + }; + const handleBatchTest = async (mode, providerId = null) => { if (testingMode) return; setTestingMode(mode === "provider" ? providerId : mode); @@ -509,9 +532,12 @@ export default function ProvidersPage() { provider={info} stats={getProviderStats(key, freeAuthTypes)} authType="free" + isFree + providerStrategies={providerStrategies} onToggle={(active) => handleToggleProvider(key, freeAuthTypes, active) } + onToggleNoAuth={(active) => handleToggleNoAuthProvider(key, active)} /> ); })} @@ -653,9 +679,23 @@ export default function ProvidersPage() { ); } -function ProviderCard({ providerId, provider, stats, authType, onToggle }) { +function ProviderCard({ providerId, provider, stats, authType, onToggle, providerStrategies, onToggleNoAuth, isFree }) { const { connected, error, errorCode, errorTime, allDisabled } = stats; const isNoAuth = !!provider.noAuth; + const isNoAuthDisabled = isNoAuth && providerStrategies?.[providerId]?.enabled === false; + const effectiveDisabled = allDisabled || isNoAuthDisabled; + + // Free providers without real connections (noAuth, or free OAuth like gemini-cli + // with zero connections) toggle via providerStrategies.enabled. + const usesNoAuthToggle = isNoAuth || (isFree && stats.total === 0); + + const handleToggleClick = () => { + if (usesNoAuthToggle) { + onToggleNoAuth(effectiveDisabled); + } else { + onToggle(!allDisabled ? false : true); + } + }; const dotColors = { free: "bg-green-500", @@ -674,7 +714,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
@@ -698,7 +738,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {

{provider.name}

- {allDisabled ? ( + {effectiveDisabled ? ( @@ -721,20 +761,20 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
- {stats.total > 0 && ( + {(stats.total > 0 || usesNoAuthToggle) && (
{ e.preventDefault(); e.stopPropagation(); - onToggle(!allDisabled ? false : true); + handleToggleClick(); }} > {}} - title={allDisabled ? "Enable provider" : "Disable provider"} + title={effectiveDisabled ? "Enable provider" : "Disable provider"} />
)} @@ -752,15 +792,21 @@ ProviderCard.propTypes = { name: PropTypes.string.isRequired, color: PropTypes.string, textIcon: PropTypes.string, + noAuth: PropTypes.bool, }).isRequired, stats: PropTypes.shape({ connected: PropTypes.number, error: PropTypes.number, errorCode: PropTypes.string, errorTime: PropTypes.string, + total: PropTypes.number, + allDisabled: PropTypes.bool, }).isRequired, authType: PropTypes.string, onToggle: PropTypes.func, + providerStrategies: PropTypes.object, + onToggleNoAuth: PropTypes.func, + isFree: PropTypes.bool, }; function ApiKeyProviderCard({ diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 6a713433..1119b916 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; import { resetComboRotation } from "open-sse/services/combo.js"; +import { setLogLevel } from "@/sse/utils/logger"; import bcrypt from "bcryptjs"; export const dynamic = "force-dynamic"; @@ -78,6 +79,11 @@ export async function PATCH(request) { const settings = await updateSettings(body); + // Apply log level immediately (no restart required) + if (Object.prototype.hasOwnProperty.call(body, "logLevel")) { + setLogLevel(body.logLevel); + } + // Apply outbound proxy settings immediately (no restart required) if ( Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") || diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 7bae5b9f..8eb12076 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -27,6 +27,7 @@ const DEFAULT_SETTINGS = { requireApiKey: true, tunnelDashboardAccess: true, authMode: "password", + logLevel: "info", oidcIssuerUrl: "", oidcClientId: "", oidcClientSecret: "", diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 5b27f774..6994c209 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -3,6 +3,7 @@ import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { existsSync } from "fs"; import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb"; +import { setLogLevel } from "@/sse/utils/logger"; import { enableTunnel, enableTailscale, isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting, @@ -83,6 +84,14 @@ async function runHeavyStartup() { await cleanupProviderConnections(); const settings = await getSettings(); + // Apply persisted log level (dashboard Settings โ†’ Logging) so production + // logs stay quiet (ERROR only) even after a restart. + try { + setLogLevel(settings.logLevel); + } catch (e) { + console.warn("[InitApp] setLogLevel failed:", e.message); + } + // Auto-resume tunnel (once per process) if (settings.tunnelEnabled && !g.tunnelAutoResumed) { g.tunnelAutoResumed = true; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index b07db1fc..5e4a620e 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -41,10 +41,19 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu // Resolve alias to provider ID (e.g., "kc" -> "kilocode") const providerId = resolveProviderId(provider); + // Any free-tier provider can be toggled off via settings (enabled: false), + // same as disabling connections. Checked before both the noAuth branch and + // the normal connections branch so e.g. gemini-cli (free OAuth) can also be turned off. + const earlySettings = await getSettings(); + const earlyOverride = (earlySettings.providerStrategies || {})[providerId] || {}; + if (FREE_PROVIDERS[providerId] && earlyOverride.enabled === false) { + log.debug("AUTH", `${provider} | free provider disabled via settings`); + return null; + } + // Inject a virtual connection for no-auth free providers (with optional proxy pool from settings) if (FREE_PROVIDERS[providerId]?.noAuth) { - const settings = await getSettings(); - const override = (settings.providerStrategies || {})[providerId] || {}; + const override = earlyOverride; const strategy = override.rotateStrategy || "none"; let pickedId = override.proxyPoolId || null; if (strategy !== "none") { diff --git a/src/sse/utils/logger.js b/src/sse/utils/logger.js index a9c631ed..bbabb1e1 100644 --- a/src/sse/utils/logger.js +++ b/src/sse/utils/logger.js @@ -7,7 +7,23 @@ const LOG_LEVELS = { ERROR: 3 }; -const LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO; +// Runtime log level. Defaults from LOG_LEVEL env, but can be changed at runtime +// via setLogLevel (dashboard Settings โ†’ Logging). WARN/ERROR hide the noisy +// INFO request lines (โ–ถ POST / ๐Ÿ“Š DONE / [COMBO] / [CHAT] ...) in production. +let LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO; + +export function setLogLevel(level) { + const normalized = String(level || "").toUpperCase(); + if (Object.prototype.hasOwnProperty.call(LOG_LEVELS, normalized)) { + LEVEL = LOG_LEVELS[normalized]; + return true; + } + return false; +} + +export function getLogLevel() { + return Object.keys(LOG_LEVELS).find((key) => LOG_LEVELS[key] === LEVEL) || "INFO"; +} function formatTime() { return new Date().toLocaleTimeString("en-US", { hour12: false }); @@ -33,6 +49,7 @@ export function tagForSession(seed) { } // Print one correlated line: [time] tag symbol message +// Visible at INFO and below (hidden by WARN/ERROR log levels in production). export function line(tag, symbol, message) { if (LEVEL > LOG_LEVELS.INFO) return; console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`); @@ -95,17 +112,20 @@ export function error(tag, message, data) { } export function request(method, path, extra) { + if (LEVEL > LOG_LEVELS.INFO) return; const dataStr = extra ? ` ${formatData(extra)}` : ""; console.log(`\x1b[36m[${formatTime()}] ๐Ÿ“ฅ ${method} ${path}${dataStr}\x1b[0m`); } export function response(status, duration, extra) { + if (LEVEL > LOG_LEVELS.INFO) return; const icon = status < 400 ? "๐Ÿ“ค" : "๐Ÿ’ฅ"; const dataStr = extra ? ` ${formatData(extra)}` : ""; console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`); } export function stream(event, data) { + if (LEVEL > LOG_LEVELS.INFO) return; const dataStr = data ? ` ${formatData(data)}` : ""; console.log(`[${formatTime()}] ๐ŸŒŠ [STREAM] ${event}${dataStr}`); }