mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.3.17",
|
||||
"version": "0.3.18",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
46
src/app/api/version/route.js
Normal file
46
src/app/api/version/route.js
Normal 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 });
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user