feat: configure Redis URL via UI settings

- Add redis_url to SQLite settings table (persisted across restarts)
- On startup, prefer DB-stored URL over REDIS_URL env var
- Add reconnect_redis() to support live URL changes without restart
- Settings API now accepts/returns redis_url field
- Cache tab shows editable URL input with save button and connection status
- Immediate feedback: shows Connected/Disconnected after save

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-23 18:10:11 +11:00
co-authored by Copilot
parent 08937c3b8b
commit df8bd7fa7d
6 changed files with 83 additions and 16 deletions
+13 -4
View File
@@ -12,7 +12,7 @@ from app.search import search, get_autocomplete
from app.engines import registry
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
from app.settings import get_all_settings, get_setting, set_setting
from app.cache import is_cache_available, flush_cache
from app.cache import is_cache_available, flush_cache, reconnect_redis
router = APIRouter()
@@ -236,10 +236,12 @@ async def api_remove_excluded_domain(domain: str):
class SettingsResponse(BaseModel):
cache_ttl_hours: float = Field(description="Cache TTL in hours (0 = disabled, max 168 = 1 week)")
cache_available: bool = Field(description="Whether Redis is connected and available")
redis_url: str = Field(default="", description="Redis connection URL (e.g. redis://localhost:6379)")
class UpdateSettingsRequest(BaseModel):
cache_ttl_hours: float = Field(ge=0, le=168, description="Cache TTL in hours (0 = disabled, max 168 = 1 week)")
cache_ttl_hours: float | None = Field(default=None, ge=0, le=168, description="Cache TTL in hours (0 = disabled, max 168 = 1 week)")
redis_url: str | None = Field(default=None, description="Redis connection URL (empty string to disconnect)")
@router.get(
@@ -260,6 +262,7 @@ async def api_get_settings():
return SettingsResponse(
cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")),
cache_available=is_cache_available(),
redis_url=settings.get("redis_url", ""),
)
@@ -279,10 +282,16 @@ curl -X PUT '$BASE_URL/api/settings' \\
tags=["Settings"],
)
async def api_update_settings(body: UpdateSettingsRequest):
set_setting("cache_ttl_hours", str(body.cache_ttl_hours))
if body.cache_ttl_hours is not None:
set_setting("cache_ttl_hours", str(body.cache_ttl_hours))
if body.redis_url is not None:
set_setting("redis_url", body.redis_url)
await reconnect_redis(body.redis_url)
settings = get_all_settings()
return SettingsResponse(
cache_ttl_hours=body.cache_ttl_hours,
cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")),
cache_available=is_cache_available(),
redis_url=settings.get("redis_url", ""),
)
+17 -3
View File
@@ -22,11 +22,25 @@ _available: bool = False
async def init_redis() -> None:
"""Try to connect to Redis. If it fails, caching is silently disabled."""
"""Try to connect to Redis using DB setting first, then env var fallback."""
from app.settings import get_setting
url = get_setting("redis_url") or os.environ.get("REDIS_URL", "")
await reconnect_redis(url)
async def reconnect_redis(url: str) -> None:
"""Connect (or reconnect) to Redis at the given URL."""
global _redis, _available
url = os.environ.get("REDIS_URL", "")
# Close existing connection first
if _redis:
try:
await _redis.aclose()
except Exception:
pass
_redis = None
_available = False
if not url:
logger.info("REDIS_URL not set — caching disabled")
logger.info("Redis URL not set — caching disabled")
return
try:
_redis = aioredis.from_url(url, decode_responses=True, socket_connect_timeout=3)
+1
View File
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
# Default values
DEFAULTS: dict[str, str] = {
"cache_ttl_hours": "6",
"redis_url": "",
}
Binary file not shown.
+50 -8
View File
@@ -246,12 +246,16 @@ function CacheTab() {
const [flushing, setFlushing] = useState(false);
const [flushMsg, setFlushMsg] = useState("");
const [ttl, setTtl] = useState(6);
const [redisUrl, setRedisUrl] = useState("");
const [urlSaving, setUrlSaving] = useState(false);
const [urlMsg, setUrlMsg] = useState("");
useEffect(() => {
getSettings()
.then((s) => {
setSettings(s);
setTtl(s.cache_ttl_hours);
setRedisUrl(s.redis_url);
})
.finally(() => setLoading(false));
}, []);
@@ -269,6 +273,20 @@ function CacheTab() {
}
};
const handleUrlSave = async () => {
setUrlSaving(true);
setUrlMsg("");
try {
const updated = await updateSettings({ redis_url: redisUrl.trim() });
setSettings(updated);
setUrlMsg(updated.cache_available ? "Connected ✓" : redisUrl.trim() ? "Connection failed" : "Disconnected");
} catch {
setUrlMsg("Failed to save");
} finally {
setUrlSaving(false);
}
};
const handleFlush = async () => {
setFlushing(true);
setFlushMsg("");
@@ -286,22 +304,46 @@ function CacheTab() {
return (
<div className="space-y-5">
{/* Redis URL configuration */}
<div>
<div className="flex items-center gap-2 mb-1">
<Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium">Redis Cache</p>
<p className="text-sm font-medium">Redis Connection</p>
</div>
<p className="text-sm text-muted-foreground mb-3">
{settings?.cache_available
? "Redis is connected. Repeated searches are served from cache."
: "Redis is not configured. Set REDIS_URL to enable caching."}
<p className="text-xs text-muted-foreground mb-2">
Enter a Redis URL to enable search result caching (e.g. redis://localhost:6379).
</p>
<div className={cn("rounded-lg border p-1 inline-flex items-center", !settings?.cache_available && "opacity-50 pointer-events-none")}>
<div className="flex gap-2">
<input
type="url"
value={redisUrl}
onChange={(e) => { setRedisUrl(e.target.value); setUrlMsg(""); }}
onKeyDown={(e) => { if (e.key === "Enter") handleUrlSave(); }}
placeholder="redis://host:port"
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label="Redis URL"
/>
<button
onClick={handleUrlSave}
disabled={urlSaving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{urlSaving ? "Saving…" : "Save"}
</button>
</div>
<div className="flex items-center gap-2 mt-2">
<span className={cn(
"inline-block h-2 w-2 rounded-full mr-2 ml-1",
"inline-block h-2 w-2 rounded-full",
settings?.cache_available ? "bg-green-500" : "bg-muted-foreground"
)} />
<span className="text-xs text-muted-foreground mr-2">{settings?.cache_available ? "Connected" : "Disconnected"}</span>
<span className="text-xs text-muted-foreground">
{settings?.cache_available ? "Connected" : "Disconnected"}
</span>
{urlMsg && (
<span className={cn("text-xs font-medium", urlMsg.includes("✓") ? "text-green-600 dark:text-green-400" : urlMsg === "Disconnected" ? "text-muted-foreground" : "text-destructive")}>
{urlMsg}
</span>
)}
</div>
</div>
+2 -1
View File
@@ -146,6 +146,7 @@ export async function removeExcludedDomain(domain: string): Promise<string[]> {
export interface AppSettings {
cache_ttl_hours: number;
cache_available: boolean;
redis_url: string;
}
export async function getSettings(): Promise<AppSettings> {
@@ -154,7 +155,7 @@ export async function getSettings(): Promise<AppSettings> {
return resp.json();
}
export async function updateSettings(settings: { cache_ttl_hours: number }): Promise<AppSettings> {
export async function updateSettings(settings: { cache_ttl_hours?: number; redis_url?: string }): Promise<AppSettings> {
const resp = await fetch(`${API_BASE}/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },