chore: update version and enhance dashboard components

- Add a warning message for Windows users in AntigravityToolCard to run the terminal as Administrator for MITM functionality.
- Refactor model testing logic in ProviderDetailPage to improve state management and user experience.
- Introduce new version notification in Sidebar for available updates.
This commit is contained in:
decolua
2026-02-28 16:33:18 +07:00
parent 2f4b813c5b
commit a84477e815
5 changed files with 104 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.3.17",
"version": "0.3.18",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -351,6 +351,14 @@ export default function AntigravityToolCard({
</>
)}
{/* Windows admin warning */}
{!isRunning && isWindows && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-600 border border-yellow-500/20">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span>Windows: Run terminal (9Router) as Administrator to enable MITM</span>
</div>
)}
{/* When stopped: how it works */}
{!isRunning && (
<div className="flex flex-col gap-1.5 px-1">

View File

@@ -25,7 +25,7 @@ export default function ProviderDetailPage() {
const [modelAliases, setModelAliases] = useState({});
const [headerImgError, setHeaderImgError] = useState(false);
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(false);
const [testingModelId, setTestingModelId] = useState(null);
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const { copied, copy } = useCopyToClipboard();
@@ -259,24 +259,21 @@ export default function ProviderDetailPage() {
}
};
const handleTestModels = async () => {
if (testingModels) return;
const conn = connections.find((c) => c.isActive !== false) || connections[0];
if (!conn) return;
setTestingModels(true);
setModelTestResults({});
const handleTestModel = async (modelId) => {
if (testingModelId) return;
setTestingModelId(modelId);
try {
const res = await fetch(`/api/providers/${conn.id}/test-models`, { method: "POST" });
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }),
});
const data = await res.json();
if (res.ok) {
const map = {};
for (const r of data.results || []) map[r.modelId] = r.ok ? "ok" : "error";
setModelTestResults(map);
}
setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" }));
} catch {
// silent fail
setModelTestResults((prev) => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModels(false);
setTestingModelId(null);
}
};
@@ -342,6 +339,8 @@ export default function ProviderDetailPage() {
onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)}
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelId === model.id}
/>
);
})}
@@ -559,18 +558,6 @@ export default function ProviderDetailPage() {
<h2 className="text-lg font-semibold">
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
</h2>
{connections.length > 0 && (
<Button
size="sm"
variant="secondary"
icon="science"
loading={testingModels}
onClick={handleTestModels}
disabled={testingModels}
>
{testingModels ? "Testing…" : "Test Models"}
</Button>
)}
</div>
{renderModelsSection()}
</Card>
@@ -638,7 +625,7 @@ export default function ProviderDetailPage() {
);
}
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, onDeleteAlias }) {
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, onDeleteAlias, onTest, isTesting }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -660,6 +647,18 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCusto
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
{onTest && (
<button
onClick={onTest}
disabled={isTesting}
className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
title="Test model"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
</span>
</button>
)}
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
@@ -693,6 +692,8 @@ ModelRow.propTypes = {
testStatus: PropTypes.oneOf(["ok", "error"]),
isCustom: PropTypes.bool,
onDeleteAlias: PropTypes.func,
onTest: PropTypes.func,
isTesting: PropTypes.bool,
};
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {

View File

@@ -0,0 +1,46 @@
import https from "https";
import pkg from "../../../../package.json" with { type: "json" };
const NPM_PACKAGE_NAME = "9router";
// Fetch latest version from npm registry
function fetchLatestVersion() {
return new Promise((resolve) => {
const req = https.get(
`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`,
{ timeout: 4000 },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data).version || null);
} catch {
resolve(null);
}
});
}
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
function compareVersions(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return 1;
if (pa[i] < pb[i]) return -1;
}
return 0;
}
export async function GET() {
const latestVersion = await fetchLatestVersion();
console.log("🚀 ~ GET ~ latestVersion:", latestVersion)
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;
return Response.json({ currentVersion, latestVersion, hasUpdate });
}

View File

@@ -32,6 +32,7 @@ export default function Sidebar({ onClose }) {
const [isShuttingDown, setIsShuttingDown] = useState(false);
const [isDisconnected, setIsDisconnected] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
// Check if debug mode is enabled
useEffect(() => {
@@ -41,6 +42,14 @@ export default function Sidebar({ onClose }) {
.catch(() => {});
}, []);
// Lazy check for new npm version on mount
useEffect(() => {
fetch("/api/version")
.then(res => res.json())
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
.catch(() => {});
}, []);
const isActive = (href) => {
if (href === "/dashboard/endpoint") {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
@@ -71,7 +80,7 @@ export default function Sidebar({ onClose }) {
</div>
{/* Logo */}
<div className="px-6 py-4">
<div className="px-6 py-4 flex flex-col gap-2">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#f97815] to-[#c2590a]">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
@@ -83,6 +92,16 @@ export default function Sidebar({ onClose }) {
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
</div>
</Link>
{updateInfo && (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-green-600 dark:text-amber-500">
New version available: v{updateInfo.latestVersion}
</span>
<code className="text-[10px] text-green-600/80 dark:text-amber-400/70 font-mono select-all">
npm install -g 9router@latest
</code>
</div>
)}
</div>
{/* Navigation */}