mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
* Improve dashboard responsive layouts * Improve proxy pools mobile layout * Improve CLI tool card input responsiveness --------- Co-authored-by: Delynn Assistant <zhen@dkzhen.org>
665 lines
26 KiB
JavaScript
665 lines
26 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Card, Button, Toggle, Input } from "@/shared/components";
|
|
import { useTheme } from "@/shared/hooks/useTheme";
|
|
import { cn } from "@/shared/utils/cn";
|
|
import { APP_CONFIG } from "@/shared/constants/config";
|
|
|
|
export default function ProfilePage() {
|
|
const { theme, setTheme, isDark } = useTheme();
|
|
const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });
|
|
const [loading, setLoading] = useState(true);
|
|
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
|
|
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
|
|
const [passLoading, setPassLoading] = useState(false);
|
|
const [dbLoading, setDbLoading] = useState(false);
|
|
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
|
|
const importFileRef = useRef(null);
|
|
const [proxyForm, setProxyForm] = useState({
|
|
outboundProxyEnabled: false,
|
|
outboundProxyUrl: "",
|
|
outboundNoProxy: "",
|
|
});
|
|
const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" });
|
|
const [proxyLoading, setProxyLoading] = useState(false);
|
|
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/settings")
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
setSettings(data);
|
|
setProxyForm({
|
|
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
|
outboundProxyUrl: data?.outboundProxyUrl || "",
|
|
outboundNoProxy: data?.outboundNoProxy || "",
|
|
});
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch settings:", err);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
const updateOutboundProxy = async (e) => {
|
|
e.preventDefault();
|
|
if (settings.outboundProxyEnabled !== true) return;
|
|
setProxyLoading(true);
|
|
setProxyStatus({ type: "", message: "" });
|
|
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
outboundProxyUrl: proxyForm.outboundProxyUrl,
|
|
outboundNoProxy: proxyForm.outboundNoProxy,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
setSettings((prev) => ({ ...prev, ...data }));
|
|
setProxyStatus({ type: "success", message: "Proxy settings applied" });
|
|
} else {
|
|
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
|
|
}
|
|
} catch (err) {
|
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
|
} finally {
|
|
setProxyLoading(false);
|
|
}
|
|
};
|
|
|
|
const testOutboundProxy = async () => {
|
|
if (settings.outboundProxyEnabled !== true) return;
|
|
|
|
const proxyUrl = (proxyForm.outboundProxyUrl || "").trim();
|
|
if (!proxyUrl) {
|
|
setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" });
|
|
return;
|
|
}
|
|
|
|
setProxyTestLoading(true);
|
|
setProxyStatus({ type: "", message: "" });
|
|
|
|
try {
|
|
const res = await fetch("/api/settings/proxy-test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ proxyUrl }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok && data?.ok) {
|
|
setProxyStatus({
|
|
type: "success",
|
|
message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`,
|
|
});
|
|
} else {
|
|
setProxyStatus({
|
|
type: "error",
|
|
message: data?.error || "Proxy test failed",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
|
} finally {
|
|
setProxyTestLoading(false);
|
|
}
|
|
};
|
|
|
|
const updateOutboundProxyEnabled = async (outboundProxyEnabled) => {
|
|
setProxyLoading(true);
|
|
setProxyStatus({ type: "", message: "" });
|
|
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ outboundProxyEnabled }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
setSettings((prev) => ({ ...prev, ...data }));
|
|
setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true }));
|
|
setProxyStatus({
|
|
type: "success",
|
|
message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled",
|
|
});
|
|
} else {
|
|
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
|
|
}
|
|
} catch (err) {
|
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
|
} finally {
|
|
setProxyLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordChange = async (e) => {
|
|
e.preventDefault();
|
|
if (passwords.new !== passwords.confirm) {
|
|
setPassStatus({ type: "error", message: "Passwords do not match" });
|
|
return;
|
|
}
|
|
|
|
setPassLoading(true);
|
|
setPassStatus({ type: "", message: "" });
|
|
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
currentPassword: passwords.current,
|
|
newPassword: passwords.new,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
setPassStatus({ type: "success", message: "Password updated successfully" });
|
|
setPasswords({ current: "", new: "", confirm: "" });
|
|
} else {
|
|
setPassStatus({ type: "error", message: data.error || "Failed to update password" });
|
|
}
|
|
} catch (err) {
|
|
setPassStatus({ type: "error", message: "An error occurred" });
|
|
} finally {
|
|
setPassLoading(false);
|
|
}
|
|
};
|
|
|
|
const updateFallbackStrategy = async (strategy) => {
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fallbackStrategy: strategy }),
|
|
});
|
|
if (res.ok) {
|
|
setSettings(prev => ({ ...prev, fallbackStrategy: strategy }));
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to update settings:", err);
|
|
}
|
|
};
|
|
|
|
const updateComboStrategy = async (strategy) => {
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ comboStrategy: strategy }),
|
|
});
|
|
if (res.ok) {
|
|
setSettings(prev => ({ ...prev, comboStrategy: strategy }));
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to update combo strategy:", err);
|
|
}
|
|
};
|
|
|
|
const updateStickyLimit = async (limit) => {
|
|
const numLimit = parseInt(limit);
|
|
if (isNaN(numLimit) || numLimit < 1) return;
|
|
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ stickyRoundRobinLimit: numLimit }),
|
|
});
|
|
if (res.ok) {
|
|
setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit }));
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to update sticky limit:", err);
|
|
}
|
|
};
|
|
|
|
const updateRequireLogin = async (requireLogin) => {
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ requireLogin }),
|
|
});
|
|
if (res.ok) {
|
|
setSettings(prev => ({ ...prev, requireLogin }));
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to update require login:", err);
|
|
}
|
|
};
|
|
|
|
const updateObservabilityEnabled = async (enabled) => {
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enableObservability: enabled }),
|
|
});
|
|
if (res.ok) {
|
|
setSettings(prev => ({ ...prev, enableObservability: enabled }));
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to update enableObservability:", err);
|
|
}
|
|
};
|
|
|
|
const reloadSettings = async () => {
|
|
try {
|
|
const res = await fetch("/api/settings");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
setSettings(data);
|
|
} catch (err) {
|
|
console.error("Failed to reload settings:", err);
|
|
}
|
|
};
|
|
|
|
const handleExportDatabase = async () => {
|
|
setDbLoading(true);
|
|
setDbStatus({ type: "", message: "" });
|
|
try {
|
|
const res = await fetch("/api/settings/database");
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || "Failed to export database");
|
|
}
|
|
|
|
const payload = await res.json();
|
|
const content = JSON.stringify(payload, null, 2);
|
|
const blob = new Blob([content], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
const stamp = new Date().toISOString().replace(/[.:]/g, "-");
|
|
anchor.href = url;
|
|
anchor.download = `9router-backup-${stamp}.json`;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
URL.revokeObjectURL(url);
|
|
|
|
setDbStatus({ type: "success", message: "Database backup downloaded" });
|
|
} catch (err) {
|
|
setDbStatus({ type: "error", message: err.message || "Failed to export database" });
|
|
} finally {
|
|
setDbLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleImportDatabase = async (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setDbLoading(true);
|
|
setDbStatus({ type: "", message: "" });
|
|
|
|
try {
|
|
const raw = await file.text();
|
|
const payload = JSON.parse(raw);
|
|
|
|
const res = await fetch("/api/settings/database", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "Failed to import database");
|
|
}
|
|
|
|
await reloadSettings();
|
|
setDbStatus({ type: "success", message: "Database imported successfully" });
|
|
} catch (err) {
|
|
setDbStatus({ type: "error", message: err.message || "Invalid backup file" });
|
|
} finally {
|
|
if (importFileRef.current) {
|
|
importFileRef.current.value = "";
|
|
}
|
|
setDbLoading(false);
|
|
}
|
|
};
|
|
|
|
const observabilityEnabled = settings.enableObservability === true;
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto px-4 sm:px-0">
|
|
<div className="flex flex-col gap-6">
|
|
{/* Local Mode Info */}
|
|
<Card>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
|
<div className="flex items-center gap-3 sm:gap-4">
|
|
<div className="size-10 sm:size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center shrink-0">
|
|
<span className="material-symbols-outlined text-xl sm:text-2xl">computer</span>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg sm:text-xl font-semibold">Local Mode</h2>
|
|
<p className="text-sm text-text-muted">Running on your machine</p>
|
|
</div>
|
|
</div>
|
|
<div className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5 w-full sm:w-auto">
|
|
{["light", "dark", "system"].map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
onClick={() => setTheme(option)}
|
|
className={cn(
|
|
"flex items-center justify-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-md font-medium transition-all flex-1 sm:flex-initial",
|
|
theme === option
|
|
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
|
: "text-text-muted hover:text-text-main"
|
|
)}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">
|
|
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
|
|
</span>
|
|
<span className="capitalize text-xs sm:text-sm">{option}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-3 pt-4 border-t border-border">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2">
|
|
<div>
|
|
<p className="font-medium text-sm sm:text-base">Database Location</p>
|
|
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db.json</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
icon="download"
|
|
onClick={handleExportDatabase}
|
|
loading={dbLoading}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Download Backup
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
icon="upload"
|
|
onClick={() => importFileRef.current?.click()}
|
|
disabled={dbLoading}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Import Backup
|
|
</Button>
|
|
<input
|
|
ref={importFileRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className="hidden"
|
|
onChange={handleImportDatabase}
|
|
/>
|
|
</div>
|
|
{dbStatus.message && (
|
|
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
|
{dbStatus.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Security */}
|
|
<Card>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg bg-primary/10 text-primary shrink-0">
|
|
<span className="material-symbols-outlined text-[20px]">shield</span>
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold">Security</h3>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-start sm:items-center justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Require login</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">
|
|
When ON, dashboard requires password. When OFF, access without login.
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.requireLogin === true}
|
|
onChange={() => updateRequireLogin(!settings.requireLogin)}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
{settings.requireLogin === true && (
|
|
<form onSubmit={handlePasswordChange} className="flex flex-col gap-4 pt-4 border-t border-border/50">
|
|
{settings.hasPassword && (
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-xs sm:text-sm font-medium">Current Password</label>
|
|
<Input
|
|
type="password"
|
|
placeholder="Enter current password"
|
|
value={passwords.current}
|
|
onChange={(e) => setPasswords({ ...passwords, current: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* {!settings.hasPassword && (
|
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
|
<p className="text-sm text-blue-600 dark:text-blue-400">
|
|
Setting password for the first time. Leave current password empty or use default: <code className="bg-blue-500/20 px-1 rounded">123456</code>
|
|
</p>
|
|
</div>
|
|
)} */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-xs sm:text-sm font-medium">New Password</label>
|
|
<Input
|
|
type="password"
|
|
placeholder="Enter new password"
|
|
value={passwords.new}
|
|
onChange={(e) => setPasswords({ ...passwords, new: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-xs sm:text-sm font-medium">Confirm New Password</label>
|
|
<Input
|
|
type="password"
|
|
placeholder="Confirm new password"
|
|
value={passwords.confirm}
|
|
onChange={(e) => setPasswords({ ...passwords, confirm: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{passStatus.message && (
|
|
<p className={`text-xs sm:text-sm ${passStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
|
{passStatus.message}
|
|
</p>
|
|
)}
|
|
|
|
<div className="pt-2">
|
|
<Button type="submit" variant="primary" loading={passLoading} className="w-full sm:w-auto">
|
|
{settings.hasPassword ? "Update Password" : "Set Password"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Routing Preferences */}
|
|
<Card>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
|
|
<span className="material-symbols-outlined text-[20px]">route</span>
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold">Routing Strategy</h3>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-start sm:items-center justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Round Robin</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">
|
|
Cycle through accounts to distribute load
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.fallbackStrategy === "round-robin"}
|
|
onChange={() => updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sticky Round Robin Limit */}
|
|
{settings.fallbackStrategy === "round-robin" && (
|
|
<div className="flex items-start sm:items-center justify-between gap-4 pt-2 border-t border-border/50">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Sticky Limit</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">
|
|
Calls per account before switching
|
|
</p>
|
|
</div>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={settings.stickyRoundRobinLimit || 3}
|
|
onChange={(e) => updateStickyLimit(e.target.value)}
|
|
disabled={loading}
|
|
className="w-16 sm:w-20 text-center shrink-0"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Combo Round Robin */}
|
|
<div className="flex items-start sm:items-center justify-between gap-4 pt-4 border-t border-border/50">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Combo Round Robin</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">
|
|
Cycle through providers in combos instead of always starting with first
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.comboStrategy === "round-robin"}
|
|
onChange={() => updateComboStrategy(settings.comboStrategy === "round-robin" ? "fallback" : "round-robin")}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs text-text-muted italic pt-2 border-t border-border/50">
|
|
{settings.fallbackStrategy === "round-robin"
|
|
? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`
|
|
: "Currently using accounts in priority order (Fill First)."}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Network */}
|
|
<Card>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 shrink-0">
|
|
<span className="material-symbols-outlined text-[20px]">wifi</span>
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold">Network</h3>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-start sm:items-center justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Outbound Proxy</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">Enable proxy for OAuth + provider outbound requests.</p>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.outboundProxyEnabled === true}
|
|
onChange={() => updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))}
|
|
disabled={loading || proxyLoading}
|
|
/>
|
|
</div>
|
|
|
|
{settings.outboundProxyEnabled === true && (
|
|
<form onSubmit={updateOutboundProxy} className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
|
<div className="flex flex-col gap-2">
|
|
<label className="font-medium text-sm sm:text-base">Proxy URL</label>
|
|
<Input
|
|
placeholder="http://127.0.0.1:7897"
|
|
value={proxyForm.outboundProxyUrl}
|
|
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))}
|
|
disabled={loading || proxyLoading}
|
|
/>
|
|
<p className="text-xs sm:text-sm text-text-muted">Leave empty to inherit existing env proxy (if any).</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 pt-2 border-t border-border/50">
|
|
<label className="font-medium text-sm sm:text-base">No Proxy</label>
|
|
<Input
|
|
placeholder="localhost,127.0.0.1"
|
|
value={proxyForm.outboundNoProxy}
|
|
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))}
|
|
disabled={loading || proxyLoading}
|
|
/>
|
|
<p className="text-xs sm:text-sm text-text-muted">Comma-separated hostnames/domains to bypass the proxy.</p>
|
|
</div>
|
|
|
|
<div className="pt-2 border-t border-border/50 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
loading={proxyTestLoading}
|
|
disabled={loading || proxyLoading}
|
|
onClick={testOutboundProxy}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Test proxy URL
|
|
</Button>
|
|
<Button type="submit" variant="primary" loading={proxyLoading} className="w-full sm:w-auto">
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{proxyStatus.message && (
|
|
<p className={`text-xs sm:text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}>
|
|
{proxyStatus.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Observability Settings */}
|
|
<Card>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-500 shrink-0">
|
|
<span className="material-symbols-outlined text-[20px]">monitoring</span>
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold">Observability</h3>
|
|
</div>
|
|
<div className="flex items-start sm:items-center justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm sm:text-base">Enable Observability</p>
|
|
<p className="text-xs sm:text-sm text-text-muted">
|
|
Record request details for inspection in the logs view
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={observabilityEnabled}
|
|
onChange={updateObservabilityEnabled}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* App Info */}
|
|
<div className="text-center text-xs sm:text-sm text-text-muted py-4">
|
|
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
|
|
<p className="mt-1">Local Mode - All data stored on your machine</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|