Migration: consolidate Echo6 docs to cortex with full infrastructure cleanup sync
- Documents recent infrastructure cleanup (8 CTs destroyed, 35 DNS records removed, Headscale cleanup) - Adds 24 new runbooks covering Authentik, PeerTube, Meshtastic, RECON, Proxmox, Mailcow, Internet Archive, GPU routing - Adds project documentation for headscale, vaultwarden, peertube, matrix, mmud, advbbs, arr stack - Updates services.md, environment.md, caddy.md, authentik.md to match live infrastructure - Removes 4 deprecated runbook duplicates (canonical versions live in projects/) - Adds .gitignore for binary archives and editor temp files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89834796ff
commit
e9231ac24a
93 changed files with 51223 additions and 254 deletions
696
assets/echo6-custom.css
Normal file
696
assets/echo6-custom.css
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════
|
||||
echo6 // searxng custom theme
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
colors: cyan #28C0E8 / yellow #F0D848 (extracted from logo)
|
||||
font: JetBrains Mono
|
||||
aesthetic: cyberpunk — dark, sharp, clean, minimal glow
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* ─── css variables ─── */
|
||||
:root {
|
||||
/* echo6 brand */
|
||||
--e6-cyan: #28C0E8;
|
||||
--e6-cyan-light: #5DD4F5;
|
||||
--e6-cyan-dim: rgba(40, 192, 232, 0.12);
|
||||
--e6-yellow: #F0D848;
|
||||
--e6-yellow-light: #F5E470;
|
||||
--e6-yellow-dim: rgba(240, 216, 72, 0.1);
|
||||
|
||||
/* backgrounds */
|
||||
--e6-bg: #0a0e17;
|
||||
--e6-bg-card: #111827;
|
||||
--e6-bg-hover: #1a2332;
|
||||
--e6-bg-footer: #060a10;
|
||||
|
||||
/* borders */
|
||||
--e6-border: #1e3a5f;
|
||||
|
||||
/* text */
|
||||
--e6-text: #e0e6ed;
|
||||
--e6-text-muted: #7a8ca0;
|
||||
|
||||
/* buttons */
|
||||
--e6-btn-bg: #1a2332;
|
||||
--e6-btn-hover: #243447;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
1. global — font, background, text
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
* {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: var(--e6-bg) !important;
|
||||
color: var(--e6-text) !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
2. homepage — viewport lock, centering
|
||||
═══════════════════════════════════════════
|
||||
homepage only — no scroll, logo centered
|
||||
at ~35-40% from top like google.com.
|
||||
CC: replace selectors if actual DOM differs.
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* lock homepage to viewport — no scrollbar */
|
||||
html:has(body.index),
|
||||
body.index {
|
||||
height: 100vh !important;
|
||||
overflow: hidden !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* center the main content area */
|
||||
body.index main,
|
||||
body.index #main_index,
|
||||
body.index .search-margin {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: calc(100vh - 80px) !important;
|
||||
min-height: unset !important;
|
||||
padding-bottom: 10vh !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
3. hide default searxng branding
|
||||
═══════════════════════════════════════════
|
||||
kill the giant "SearXNG" text behind the
|
||||
logo. only the echo6 logo should show.
|
||||
CC: inspect DOM and add the real selector
|
||||
if these don't catch it.
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.searxng-wordmark,
|
||||
#main-logo span,
|
||||
.index h1,
|
||||
h1.title,
|
||||
.search-margin h1,
|
||||
#main_index h1,
|
||||
.index .title,
|
||||
.title_h1 {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
font-size: 0 !important;
|
||||
color: transparent !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
4. logo
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.logo img,
|
||||
.search-margin img,
|
||||
#main-logo img,
|
||||
img[src*="searxng"] {
|
||||
max-width: 270px;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
5. search bar — pill shape, subtle focus
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
#search_form .search_box,
|
||||
.search_box,
|
||||
#q {
|
||||
background-color: var(--e6-bg-card) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 24px !important;
|
||||
color: var(--e6-text) !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* focused state — subtle cyan border, barely-there shadow */
|
||||
#search_form .search_box:focus-within,
|
||||
.search_box:focus-within {
|
||||
border-color: var(--e6-cyan) !important;
|
||||
box-shadow: 0 0 6px var(--e6-cyan-dim) !important;
|
||||
}
|
||||
|
||||
#search_form input[type="text"],
|
||||
#search_form input[type="search"],
|
||||
#q {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
font-size: 16px !important;
|
||||
color: var(--e6-text) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* placeholder text */
|
||||
#q::placeholder,
|
||||
input[type="search"]::placeholder {
|
||||
color: var(--e6-text-muted) !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* search bar container width */
|
||||
#search_form,
|
||||
.search_box {
|
||||
max-width: 584px !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
6. search buttons
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
#search_form button,
|
||||
#search_form input[type="submit"],
|
||||
.search_filters button,
|
||||
.search_submit,
|
||||
.search_box button {
|
||||
background-color: var(--e6-btn-bg) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 4px !important;
|
||||
color: var(--e6-text) !important;
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
#search_form button:hover,
|
||||
#search_form input[type="submit"]:hover,
|
||||
.search_submit:hover,
|
||||
.search_box button:hover {
|
||||
background-color: var(--e6-btn-hover) !important;
|
||||
border-color: var(--e6-cyan) !important;
|
||||
color: var(--e6-text) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
7. top navigation bar
|
||||
═══════════════════════════════════════════
|
||||
injected via template override.
|
||||
styles the .//photos .//mail links, waffle
|
||||
menu button, and login avatar.
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.echo6-nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 10px 18px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background-color: transparent;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* .//photos and .//mail links */
|
||||
.echo6-nav a.echo6-nav-link {
|
||||
color: var(--e6-cyan) !important;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: lowercase;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.echo6-nav a.echo6-nav-link:hover {
|
||||
color: var(--e6-cyan-light) !important;
|
||||
}
|
||||
|
||||
/* waffle menu (⠿) button */
|
||||
.echo6-waffle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--e6-text-muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.echo6-waffle-btn:hover {
|
||||
background-color: var(--e6-bg-hover);
|
||||
}
|
||||
|
||||
/* login avatar button */
|
||||
.echo6-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--e6-border);
|
||||
background: transparent;
|
||||
color: var(--e6-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.echo6-login-btn:hover {
|
||||
border-color: var(--e6-cyan);
|
||||
}
|
||||
|
||||
.echo6-login-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: var(--e6-text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
8. waffle menu dropdown
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.echo6-waffle-menu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: 70px;
|
||||
z-index: 2000;
|
||||
background-color: var(--e6-bg-card);
|
||||
border: 1px solid var(--e6-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
padding: 16px;
|
||||
min-width: 280px;
|
||||
animation: echo6FadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.echo6-waffle-menu.active {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.echo6-waffle-menu a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--e6-text);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-transform: lowercase;
|
||||
transition: background-color 0.15s ease;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.echo6-waffle-menu a:hover {
|
||||
background-color: var(--e6-bg-hover);
|
||||
}
|
||||
|
||||
.echo6-waffle-menu a img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* emoji fallback icons in waffle menu */
|
||||
.echo6-waffle-menu a .echo6-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes echo6FadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* waffle menu overlay — click to close */
|
||||
.echo6-waffle-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1999;
|
||||
}
|
||||
|
||||
.echo6-waffle-overlay.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
9. footer
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
footer,
|
||||
.footer,
|
||||
#footer,
|
||||
.searxng-footer {
|
||||
background-color: var(--e6-bg-footer) !important;
|
||||
border-top: 1px solid var(--e6-border) !important;
|
||||
text-align: center;
|
||||
padding: 12px 0 !important;
|
||||
}
|
||||
|
||||
footer *,
|
||||
.footer *,
|
||||
#footer *,
|
||||
.searxng-footer * {
|
||||
font-size: 12px !important;
|
||||
text-transform: lowercase;
|
||||
color: var(--e6-text-muted) !important;
|
||||
}
|
||||
|
||||
footer a,
|
||||
.footer a,
|
||||
#footer a,
|
||||
.searxng-footer a {
|
||||
color: var(--e6-text-muted) !important;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
footer a:hover,
|
||||
.footer a:hover,
|
||||
#footer a:hover,
|
||||
.searxng-footer a:hover {
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
10. search results page
|
||||
═══════════════════════════════════════════
|
||||
results pages must scroll normally.
|
||||
dark theme applied to result elements.
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* allow scrolling on non-homepage */
|
||||
html:not(:has(body.index)),
|
||||
body:not(.index) {
|
||||
height: auto !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* result links */
|
||||
.result a,
|
||||
.result-default a h3,
|
||||
.result-title a {
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.result a:hover,
|
||||
.result-default a:hover h3,
|
||||
.result-title a:hover {
|
||||
color: var(--e6-cyan-light) !important;
|
||||
}
|
||||
|
||||
.result a:visited,
|
||||
.result-default a:visited h3 {
|
||||
color: #8BA8C4 !important;
|
||||
}
|
||||
|
||||
/* result URLs */
|
||||
.result .url_wrapper,
|
||||
.result .url,
|
||||
.result-url {
|
||||
color: var(--e6-text-muted) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* result descriptions */
|
||||
.result .content,
|
||||
.result-content,
|
||||
.result p {
|
||||
color: var(--e6-text) !important;
|
||||
}
|
||||
|
||||
/* result cards/containers */
|
||||
.result,
|
||||
.result-default {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--e6-border) !important;
|
||||
padding: 14px 0 !important;
|
||||
}
|
||||
|
||||
/* search categories/tabs bar */
|
||||
.search_categories,
|
||||
#categories,
|
||||
.category {
|
||||
background-color: var(--e6-bg) !important;
|
||||
border-bottom: 1px solid var(--e6-border) !important;
|
||||
}
|
||||
|
||||
.search_categories label,
|
||||
.category a,
|
||||
.category button {
|
||||
color: var(--e6-text-muted) !important;
|
||||
font-size: 13px !important;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.search_categories label:hover,
|
||||
.category a:hover,
|
||||
.category button:hover {
|
||||
color: var(--e6-text) !important;
|
||||
}
|
||||
|
||||
/* active category tab */
|
||||
.search_categories label.active,
|
||||
.category.active a,
|
||||
.category.active button,
|
||||
.search_categories input:checked + label {
|
||||
color: var(--e6-cyan) !important;
|
||||
border-bottom: 2px solid var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
/* sidebar / infobox */
|
||||
.infobox,
|
||||
#sidebar {
|
||||
background-color: var(--e6-bg-card) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* pagination */
|
||||
.pagination button,
|
||||
.pagination a,
|
||||
#pagination button {
|
||||
background-color: var(--e6-btn-bg) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
color: var(--e6-text) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.pagination button:hover,
|
||||
.pagination a:hover,
|
||||
#pagination button:hover {
|
||||
border-color: var(--e6-cyan) !important;
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
/* engine stats / result info */
|
||||
.result_header,
|
||||
.result-engines,
|
||||
.engines {
|
||||
color: var(--e6-text-muted) !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
11. preferences page
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* preferences containers */
|
||||
.preferences,
|
||||
.preferences fieldset,
|
||||
.preferences form {
|
||||
background-color: var(--e6-bg) !important;
|
||||
color: var(--e6-text) !important;
|
||||
}
|
||||
|
||||
.preferences fieldset {
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 8px !important;
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
|
||||
.preferences legend {
|
||||
color: var(--e6-cyan) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* select dropdowns and inputs in preferences */
|
||||
.preferences select,
|
||||
.preferences input[type="text"],
|
||||
.preferences input[type="number"],
|
||||
.preferences textarea {
|
||||
background-color: var(--e6-bg-card) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
color: var(--e6-text) !important;
|
||||
border-radius: 4px !important;
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
|
||||
.preferences select:focus,
|
||||
.preferences input:focus,
|
||||
.preferences textarea:focus {
|
||||
border-color: var(--e6-cyan) !important;
|
||||
box-shadow: 0 0 6px var(--e6-cyan-dim) !important;
|
||||
}
|
||||
|
||||
/* preferences save button */
|
||||
.preferences input[type="submit"],
|
||||
.preferences button[type="submit"] {
|
||||
background-color: var(--e6-cyan) !important;
|
||||
border: none !important;
|
||||
color: var(--e6-bg) !important;
|
||||
font-weight: 600;
|
||||
border-radius: 4px !important;
|
||||
cursor: pointer;
|
||||
padding: 8px 24px !important;
|
||||
}
|
||||
|
||||
.preferences input[type="submit"]:hover,
|
||||
.preferences button[type="submit"]:hover {
|
||||
background-color: var(--e6-cyan-light) !important;
|
||||
}
|
||||
|
||||
/* engine toggle checkboxes */
|
||||
.preferences input[type="checkbox"]:checked {
|
||||
accent-color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
/* preferences tab navigation */
|
||||
.preferences .tabs a,
|
||||
.preferences .nav a {
|
||||
color: var(--e6-text-muted) !important;
|
||||
}
|
||||
|
||||
.preferences .tabs a:hover,
|
||||
.preferences .nav a:hover {
|
||||
color: var(--e6-text) !important;
|
||||
}
|
||||
|
||||
.preferences .tabs a.active,
|
||||
.preferences .nav a.active {
|
||||
color: var(--e6-cyan) !important;
|
||||
border-bottom: 2px solid var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
12. scrollbar styling
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--e6-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--e6-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2a4a6f;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
13. selection highlight
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
::selection {
|
||||
background-color: rgba(40, 192, 232, 0.25);
|
||||
color: var(--e6-text);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
14. responsive
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.echo6-nav {
|
||||
padding: 8px 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.echo6-nav a.echo6-nav-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.echo6-waffle-menu {
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.echo6-waffle-menu.active {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
#search_form,
|
||||
.search_box {
|
||||
max-width: 100% !important;
|
||||
margin-left: 12px !important;
|
||||
margin-right: 12px !important;
|
||||
}
|
||||
|
||||
.logo img,
|
||||
img[src*="searxng"] {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.echo6-waffle-menu.active {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.echo6-nav a.echo6-nav-link {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
end // echo6-custom.css
|
||||
═══════════════════════════════════════════ */
|
||||
436
assets/echo6-openwebui-theme.css
Normal file
436
assets/echo6-openwebui-theme.css
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* ╔══════════════════════════════════════════════════════════════╗
|
||||
* ║ ECHO6 — Open WebUI Theme (Togglable) v2 ║
|
||||
* ║ Cyberpunk terminal aesthetic ║
|
||||
* ║ Cyan #28C0E8 · Yellow #F0D848 · JetBrains Mono ║
|
||||
* ╚══════════════════════════════════════════════════════════════╝
|
||||
*
|
||||
* Activates when <html> has class "echo6" — toggled by the
|
||||
* companion script echo6-theme-toggle.js
|
||||
*/
|
||||
|
||||
/* ── Import JetBrains Mono ────────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||
|
||||
/* ── Palette Variables ────────────────────────────────────────── */
|
||||
.echo6 {
|
||||
--e6-cyan: #28C0E8;
|
||||
--e6-cyan-dim: #1a8aa8;
|
||||
--e6-cyan-glow: #28c0e815;
|
||||
--e6-yellow: #F0D848;
|
||||
--e6-yellow-dim: #c4b03a;
|
||||
--e6-yellow-glow: #f0d84820;
|
||||
--e6-bg-primary: #0a0e14;
|
||||
--e6-bg-secondary: #0d1117;
|
||||
--e6-bg-tertiary: #131920;
|
||||
--e6-bg-elevated: #181f28;
|
||||
--e6-text-primary: #c8d0d8;
|
||||
--e6-text-secondary: #4a5568;
|
||||
--e6-text-bright: #e2e8f0;
|
||||
--e6-border: #1a2332;
|
||||
--e6-border-subtle: #141c26;
|
||||
--e6-font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
GLOBAL
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 body {
|
||||
background-color: var(--e6-bg-primary) !important;
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
/* Font — target text elements, not * (breaks icon fonts) */
|
||||
.echo6 body,
|
||||
.echo6 p,
|
||||
.echo6 span,
|
||||
.echo6 div,
|
||||
.echo6 a,
|
||||
.echo6 button,
|
||||
.echo6 input,
|
||||
.echo6 textarea,
|
||||
.echo6 select,
|
||||
.echo6 label,
|
||||
.echo6 h1, .echo6 h2, .echo6 h3, .echo6 h4, .echo6 h5, .echo6 h6,
|
||||
.echo6 li,
|
||||
.echo6 td, .echo6 th,
|
||||
.echo6 pre, .echo6 code {
|
||||
font-family: var(--e6-font-mono) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SIDEBAR — clean, no glow pills
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Sidebar background */
|
||||
.echo6 #sidebar,
|
||||
.echo6 [class*="sidebar"],
|
||||
.echo6 aside,
|
||||
.echo6 nav {
|
||||
background-color: var(--e6-bg-secondary) !important;
|
||||
}
|
||||
|
||||
/* All sidebar text — muted by default */
|
||||
.echo6 #sidebar *,
|
||||
.echo6 aside * {
|
||||
color: var(--e6-text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Kill all existing backgrounds/glows on sidebar items */
|
||||
.echo6 #sidebar a,
|
||||
.echo6 #sidebar button,
|
||||
.echo6 aside a,
|
||||
.echo6 aside button {
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
transition: color 0.12s ease !important;
|
||||
}
|
||||
|
||||
/* Sidebar hover — just brighten the text */
|
||||
.echo6 #sidebar a:hover,
|
||||
.echo6 #sidebar a:hover *,
|
||||
.echo6 #sidebar button:hover,
|
||||
.echo6 #sidebar button:hover *,
|
||||
.echo6 aside a:hover,
|
||||
.echo6 aside a:hover * {
|
||||
color: var(--e6-cyan) !important;
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Active/selected chat — no background, just brighter text */
|
||||
.echo6 #sidebar [class*="bg-"],
|
||||
.echo6 #sidebar [aria-selected="true"] {
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.echo6 #sidebar [class*="bg-"] *,
|
||||
.echo6 #sidebar [aria-selected="true"] * {
|
||||
color: var(--e6-text-bright) !important;
|
||||
}
|
||||
|
||||
/* Section headers (Chats, Folders, Today, Yesterday) */
|
||||
.echo6 #sidebar .text-xs,
|
||||
.echo6 aside .text-xs {
|
||||
color: var(--e6-text-secondary) !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
MAIN CONTENT AREA — aggressively override all backgrounds
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 main,
|
||||
.echo6 div[class*="bg-white"],
|
||||
.echo6 div[class*="bg-gray"] {
|
||||
background-color: var(--e6-bg-primary) !important;
|
||||
}
|
||||
|
||||
/* Target OWUI's main wrapper and content divs */
|
||||
.echo6 #app > div,
|
||||
.echo6 #app > div > div,
|
||||
.echo6 [class*="h-screen"],
|
||||
.echo6 [class*="h-full"],
|
||||
.echo6 [class*="min-h-screen"] {
|
||||
background-color: var(--e6-bg-primary) !important;
|
||||
}
|
||||
|
||||
/* Model name display (center of page) */
|
||||
.echo6 [class*="text-3xl"],
|
||||
.echo6 [class*="text-2xl"] {
|
||||
color: var(--e6-text-bright) !important;
|
||||
}
|
||||
|
||||
/* ── Suggested prompts ────────────────────────────────────────── */
|
||||
/* Target the suggestion container buttons specifically */
|
||||
.echo6 [class*="suggestion"],
|
||||
.echo6 [class*="Suggestion"],
|
||||
.echo6 button[class*="cursor-pointer"][class*="flex"][class*="rounded-xl"],
|
||||
.echo6 button[class*="cursor-pointer"][class*="flex"][class*="rounded-lg"],
|
||||
.echo6 [class*="bg-gray"][class*="cursor-pointer"][class*="rounded"] {
|
||||
background-color: var(--e6-bg-tertiary) !important;
|
||||
background: var(--e6-bg-tertiary) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
.echo6 [class*="suggestion"]:hover,
|
||||
.echo6 [class*="Suggestion"]:hover,
|
||||
.echo6 button[class*="cursor-pointer"][class*="flex"][class*="rounded-xl"]:hover,
|
||||
.echo6 button[class*="cursor-pointer"][class*="flex"][class*="rounded-lg"]:hover,
|
||||
.echo6 [class*="bg-gray"][class*="cursor-pointer"][class*="rounded"]:hover {
|
||||
border-color: var(--e6-cyan) !important;
|
||||
background-color: var(--e6-bg-elevated) !important;
|
||||
background: var(--e6-bg-elevated) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
INPUT AREA
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 textarea,
|
||||
.echo6 #chat-textarea,
|
||||
.echo6 [contenteditable] {
|
||||
background-color: var(--e6-bg-tertiary) !important;
|
||||
border-color: var(--e6-border) !important;
|
||||
color: var(--e6-text-bright) !important;
|
||||
caret-color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.echo6 textarea:focus,
|
||||
.echo6 #chat-textarea:focus,
|
||||
.echo6 [contenteditable]:focus {
|
||||
border-color: var(--e6-cyan) !important;
|
||||
box-shadow: 0 0 0 1px var(--e6-cyan-glow) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Input wrapper bar */
|
||||
.echo6 [class*="bottom-0"],
|
||||
.echo6 [class*="sticky"][class*="bottom"] {
|
||||
background-color: var(--e6-bg-primary) !important;
|
||||
}
|
||||
|
||||
/* Action icons in input row */
|
||||
.echo6 textarea ~ div button,
|
||||
.echo6 [class*="input"] button {
|
||||
color: var(--e6-text-secondary) !important;
|
||||
}
|
||||
|
||||
.echo6 textarea ~ div button:hover,
|
||||
.echo6 [class*="input"] button:hover {
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
MESSAGE BUBBLES
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 [data-role="user"] > div {
|
||||
background-color: var(--e6-bg-elevated) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.echo6 [data-role="assistant"] > div {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.echo6 [data-role] * {
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
TOP BAR / HEADER
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 header,
|
||||
.echo6 [class*="top-0"][class*="sticky"],
|
||||
.echo6 [class*="top-0"][class*="fixed"] {
|
||||
background-color: var(--e6-bg-secondary) !important;
|
||||
border-bottom: 1px solid var(--e6-border-subtle) !important;
|
||||
}
|
||||
|
||||
.echo6 header button,
|
||||
.echo6 header a,
|
||||
.echo6 header span {
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
.echo6 header [class*="text-xs"],
|
||||
.echo6 header [class*="text-gray"] {
|
||||
color: var(--e6-text-secondary) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
CODE BLOCKS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 pre {
|
||||
background-color: var(--e6-bg-secondary) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
.echo6 pre code {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.echo6 :not(pre) > code {
|
||||
background-color: var(--e6-bg-elevated) !important;
|
||||
color: var(--e6-yellow) !important;
|
||||
padding: 0.15em 0.4em !important;
|
||||
border-radius: 3px !important;
|
||||
font-size: 0.9em !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
BUTTONS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 button[type="submit"],
|
||||
.echo6 button[class*="primary"] {
|
||||
background-color: var(--e6-cyan) !important;
|
||||
color: var(--e6-bg-primary) !important;
|
||||
}
|
||||
|
||||
.echo6 button[type="submit"]:hover,
|
||||
.echo6 button[class*="primary"]:hover {
|
||||
background-color: var(--e6-cyan-dim) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
DROPDOWNS / MENUS / MODALS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 [role="dialog"],
|
||||
.echo6 [role="menu"],
|
||||
.echo6 [role="listbox"],
|
||||
.echo6 [class*="dropdown"],
|
||||
.echo6 [class*="modal"],
|
||||
.echo6 [class*="popover"] {
|
||||
background-color: var(--e6-bg-tertiary) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
}
|
||||
|
||||
.echo6 [role="option"]:hover,
|
||||
.echo6 [role="menuitem"]:hover {
|
||||
background-color: var(--e6-bg-elevated) !important;
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.echo6 select {
|
||||
background-color: var(--e6-bg-tertiary) !important;
|
||||
border-color: var(--e6-border) !important;
|
||||
color: var(--e6-text-primary) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCROLLBAR
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 ::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
height: 5px !important;
|
||||
}
|
||||
|
||||
.echo6 ::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.echo6 ::-webkit-scrollbar-thumb {
|
||||
background: var(--e6-border) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.echo6 ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--e6-cyan-dim) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
LINKS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 a:not(#sidebar a):not(aside a):not(nav a):not(#echo6-toggle) {
|
||||
color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.echo6 a:not(#sidebar a):not(aside a):not(nav a):not(#echo6-toggle):hover {
|
||||
color: var(--e6-yellow) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
MISC
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.echo6 ::selection {
|
||||
background-color: var(--e6-cyan) !important;
|
||||
color: var(--e6-bg-primary) !important;
|
||||
}
|
||||
|
||||
.echo6 ::placeholder {
|
||||
color: var(--e6-text-secondary) !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
.echo6 [class*="badge"],
|
||||
.echo6 [class*="tag"],
|
||||
.echo6 [class*="chip"] {
|
||||
background-color: var(--e6-yellow-glow) !important;
|
||||
color: var(--e6-yellow) !important;
|
||||
border: 1px solid var(--e6-yellow-dim) !important;
|
||||
}
|
||||
|
||||
.echo6 [class*="spinner"],
|
||||
.echo6 [class*="loading"] {
|
||||
border-color: var(--e6-border) !important;
|
||||
border-top-color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.echo6 input[type="checkbox"]:checked + * {
|
||||
background-color: var(--e6-cyan) !important;
|
||||
}
|
||||
|
||||
.echo6 [role="tooltip"] {
|
||||
background-color: var(--e6-bg-elevated) !important;
|
||||
color: var(--e6-text-primary) !important;
|
||||
border: 1px solid var(--e6-border) !important;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
TOGGLE BUTTON (injected by echo6-theme-toggle.js)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
#echo6-toggle {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 99999;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1e2a36;
|
||||
background-color: #0f1419;
|
||||
color: #6e7a88;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.4;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#echo6-toggle:hover {
|
||||
opacity: 1;
|
||||
border-color: #28C0E8;
|
||||
color: #28C0E8;
|
||||
box-shadow: 0 0 12px #28c0e840;
|
||||
}
|
||||
|
||||
#echo6-toggle.active {
|
||||
opacity: 0.7;
|
||||
background-color: #28C0E8;
|
||||
color: #0a0e14;
|
||||
border-color: #28C0E8;
|
||||
}
|
||||
|
||||
#echo6-toggle.active:hover {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 16px #28c0e860;
|
||||
}
|
||||
49
assets/echo6-theme-toggle.js
Executable file
49
assets/echo6-theme-toggle.js
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Echo6 Theme Toggle for Open WebUI
|
||||
* Adds a small button (bottom-right) that toggles the .echo6 class
|
||||
* on <html>, activating/deactivating the companion CSS theme.
|
||||
* Persists preference in localStorage.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'echo6-theme-active';
|
||||
var html = document.documentElement;
|
||||
|
||||
// Restore saved state immediately (before paint if possible)
|
||||
var saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'true') {
|
||||
html.classList.add('echo6');
|
||||
}
|
||||
|
||||
function createToggle() {
|
||||
// Don't double-inject
|
||||
if (document.getElementById('echo6-toggle')) return;
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.id = 'echo6-toggle';
|
||||
btn.textContent = 'E6';
|
||||
btn.title = 'Toggle Echo6 theme';
|
||||
btn.setAttribute('aria-label', 'Toggle Echo6 theme');
|
||||
|
||||
// Sync active state with current class
|
||||
if (html.classList.contains('echo6')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var isActive = html.classList.toggle('echo6');
|
||||
btn.classList.toggle('active', isActive);
|
||||
localStorage.setItem(STORAGE_KEY, isActive ? 'true' : 'false');
|
||||
});
|
||||
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
// Inject once DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createToggle);
|
||||
} else {
|
||||
createToggle();
|
||||
}
|
||||
})();
|
||||
BIN
assets/echo6_favicon.png
Normal file
BIN
assets/echo6_favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/echo6_favicon_32x32.png
Normal file
BIN
assets/echo6_favicon_32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/echo6_logo.png
Normal file
BIN
assets/echo6_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/echo6yellow_logo_150x29.png
Normal file
BIN
assets/echo6yellow_logo_150x29.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5 KiB |
BIN
assets/echo6yellow_logo_422x422_square.png
Normal file
BIN
assets/echo6yellow_logo_422x422_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/echo6yellow_logo_422x81.png
Normal file
BIN
assets/echo6yellow_logo_422x81.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
270
assets/key_manager.py
Normal file
270
assets/key_manager.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""
|
||||
RECON Key Manager - Thread-safe API key management with hot-reload.
|
||||
|
||||
Provides a singleton KeyManager that workers (enricher, extractor) read from
|
||||
instead of loading .env directly. Dashboard can update keys at runtime without
|
||||
restarting the service.
|
||||
|
||||
Dependencies: None beyond stdlib + requests (already in requirements.txt)
|
||||
Config: Reads/writes /opt/recon/.env
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger('recon.key_manager')
|
||||
|
||||
class KeyManager:
|
||||
"""Thread-safe API key store with hot-reload and validation."""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._keys_lock = threading.RLock()
|
||||
self._gemini_keys = []
|
||||
self._env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')
|
||||
self._last_loaded = None
|
||||
self._key_stats = {} # key_index -> {calls, errors, last_used}
|
||||
self._load_from_env()
|
||||
self._initialized = True
|
||||
logger.info(f"KeyManager initialized with {len(self._gemini_keys)} Gemini key(s)")
|
||||
|
||||
# ── Read Operations ──
|
||||
|
||||
def get_gemini_keys(self):
|
||||
"""Return a copy of current Gemini keys. Thread-safe."""
|
||||
with self._keys_lock:
|
||||
return list(self._gemini_keys)
|
||||
|
||||
def get_gemini_key(self, index=0):
|
||||
"""Get a single Gemini key by index. Returns None if out of range."""
|
||||
with self._keys_lock:
|
||||
if 0 <= index < len(self._gemini_keys):
|
||||
return self._gemini_keys[index]
|
||||
return None
|
||||
|
||||
def get_gemini_key_count(self):
|
||||
"""Return number of loaded Gemini keys."""
|
||||
with self._keys_lock:
|
||||
return len(self._gemini_keys)
|
||||
|
||||
def get_masked_keys(self):
|
||||
"""Return keys masked for display: first 8 + ... + last 4 chars."""
|
||||
with self._keys_lock:
|
||||
result = []
|
||||
for i, key in enumerate(self._gemini_keys):
|
||||
if len(key) > 16:
|
||||
masked = key[:8] + '...' + key[-4:]
|
||||
elif len(key) > 8:
|
||||
masked = key[:4] + '...' + key[-2:]
|
||||
else:
|
||||
masked = '****'
|
||||
stats = self._key_stats.get(i, {})
|
||||
result.append({
|
||||
'index': i,
|
||||
'masked': masked,
|
||||
'length': len(key),
|
||||
'calls': stats.get('calls', 0),
|
||||
'errors': stats.get('errors', 0),
|
||||
'last_used': stats.get('last_used', None),
|
||||
'valid': stats.get('valid', None),
|
||||
'last_validated': stats.get('last_validated', None),
|
||||
})
|
||||
return result
|
||||
|
||||
# ── Write Operations (all persist to .env) ──
|
||||
|
||||
def set_gemini_keys(self, keys):
|
||||
"""Replace all Gemini keys. Persists to .env. Returns success bool."""
|
||||
# Filter empty strings
|
||||
keys = [k.strip() for k in keys if k.strip()]
|
||||
with self._keys_lock:
|
||||
self._gemini_keys = keys
|
||||
self._key_stats = {} # Reset stats on full replace
|
||||
self._persist_to_env()
|
||||
logger.info(f"Gemini keys replaced: {len(keys)} key(s) loaded")
|
||||
return True
|
||||
|
||||
def add_gemini_key(self, key):
|
||||
"""Add a single Gemini key. Persists to .env. Returns new index."""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("Key cannot be empty")
|
||||
with self._keys_lock:
|
||||
# Check for duplicates
|
||||
if key in self._gemini_keys:
|
||||
raise ValueError("Key already exists")
|
||||
self._gemini_keys.append(key)
|
||||
idx = len(self._gemini_keys) - 1
|
||||
self._persist_to_env()
|
||||
logger.info(f"Gemini key added at index {idx}")
|
||||
return idx
|
||||
|
||||
def remove_gemini_key(self, index):
|
||||
"""Remove a Gemini key by index. Persists to .env. Returns removed key (masked)."""
|
||||
with self._keys_lock:
|
||||
if index < 0 or index >= len(self._gemini_keys):
|
||||
raise IndexError(f"Key index {index} out of range (have {len(self._gemini_keys)} keys)")
|
||||
if len(self._gemini_keys) <= 1:
|
||||
raise ValueError("Cannot remove last key — pipeline needs at least 1 Gemini key")
|
||||
key = self._gemini_keys.pop(index)
|
||||
# Rebuild stats with shifted indices
|
||||
new_stats = {}
|
||||
for i, stats in self._key_stats.items():
|
||||
if i < index:
|
||||
new_stats[i] = stats
|
||||
elif i > index:
|
||||
new_stats[i - 1] = stats
|
||||
self._key_stats = new_stats
|
||||
self._persist_to_env()
|
||||
masked = key[:8] + '...' + key[-4:] if len(key) > 16 else '****'
|
||||
logger.info(f"Gemini key removed at index {index}: {masked}")
|
||||
return masked
|
||||
|
||||
def replace_gemini_key(self, index, new_key):
|
||||
"""Replace a single Gemini key at index. Persists to .env."""
|
||||
new_key = new_key.strip()
|
||||
if not new_key:
|
||||
raise ValueError("Key cannot be empty")
|
||||
with self._keys_lock:
|
||||
if index < 0 or index >= len(self._gemini_keys):
|
||||
raise IndexError(f"Key index {index} out of range")
|
||||
# Check duplicate (but allow replacing with same key)
|
||||
if new_key in self._gemini_keys and self._gemini_keys[index] != new_key:
|
||||
raise ValueError("Key already exists at another index")
|
||||
self._gemini_keys[index] = new_key
|
||||
if index in self._key_stats:
|
||||
self._key_stats[index] = {} # Reset stats for replaced key
|
||||
self._persist_to_env()
|
||||
logger.info(f"Gemini key replaced at index {index}")
|
||||
|
||||
# ── Validation ──
|
||||
|
||||
def validate_key(self, key):
|
||||
"""
|
||||
Test a Gemini API key by listing models.
|
||||
Returns (valid: bool, message: str).
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models?key={key}",
|
||||
timeout=10
|
||||
)
|
||||
if resp.status_code == 200 and 'models' in resp.text:
|
||||
return True, "Valid — API responded"
|
||||
elif resp.status_code == 400:
|
||||
return False, f"Invalid key (HTTP {resp.status_code})"
|
||||
elif resp.status_code == 403:
|
||||
return False, "Key disabled or quota exhausted"
|
||||
elif resp.status_code == 429:
|
||||
return True, "Valid — but currently rate-limited"
|
||||
else:
|
||||
return False, f"Unexpected response (HTTP {resp.status_code})"
|
||||
except requests.Timeout:
|
||||
return False, "Timeout — could not reach Gemini API"
|
||||
except requests.ConnectionError:
|
||||
return False, "Connection error — check network"
|
||||
except Exception as e:
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
def validate_all(self):
|
||||
"""Validate all loaded Gemini keys. Returns list of results."""
|
||||
results = []
|
||||
with self._keys_lock:
|
||||
keys_copy = list(enumerate(self._gemini_keys))
|
||||
|
||||
for i, key in keys_copy:
|
||||
valid, message = self.validate_key(key)
|
||||
with self._keys_lock:
|
||||
if i not in self._key_stats:
|
||||
self._key_stats[i] = {}
|
||||
self._key_stats[i]['valid'] = valid
|
||||
self._key_stats[i]['last_validated'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
results.append({'index': i, 'valid': valid, 'message': message})
|
||||
time.sleep(0.2) # Don't hammer the API
|
||||
|
||||
return results
|
||||
|
||||
# ── Stats tracking (called by enricher/extractor) ──
|
||||
|
||||
def record_usage(self, key_index, success=True):
|
||||
"""Record a key usage event. Called by workers after each Gemini call."""
|
||||
with self._keys_lock:
|
||||
if key_index not in self._key_stats:
|
||||
self._key_stats[key_index] = {'calls': 0, 'errors': 0}
|
||||
self._key_stats[key_index]['calls'] = self._key_stats[key_index].get('calls', 0) + 1
|
||||
if not success:
|
||||
self._key_stats[key_index]['errors'] = self._key_stats[key_index].get('errors', 0) + 1
|
||||
self._key_stats[key_index]['last_used'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
|
||||
# ── Internal ──
|
||||
|
||||
def _load_from_env(self):
|
||||
"""Load Gemini keys from .env file."""
|
||||
keys = []
|
||||
if os.path.exists(self._env_path):
|
||||
with open(self._env_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
match = re.match(r'^GEMINI_KEY(?:_\d+)?=(.+)$', line)
|
||||
if match:
|
||||
val = match.group(1).strip().strip('"').strip("'")
|
||||
if val:
|
||||
keys.append(val)
|
||||
self._gemini_keys = keys
|
||||
self._last_loaded = time.time()
|
||||
|
||||
def _persist_to_env(self):
|
||||
"""Write current keys back to .env file, preserving non-Gemini lines."""
|
||||
other_lines = []
|
||||
if os.path.exists(self._env_path):
|
||||
with open(self._env_path, 'r') as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r'^GEMINI_KEY', stripped):
|
||||
other_lines.append(line.rstrip('\n'))
|
||||
|
||||
with open(self._env_path, 'w') as f:
|
||||
# Write non-Gemini lines first
|
||||
for line in other_lines:
|
||||
f.write(line + '\n')
|
||||
# Write Gemini keys
|
||||
for i, key in enumerate(self._gemini_keys, 1):
|
||||
f.write(f'GEMINI_KEY_{i}={key}\n')
|
||||
|
||||
self._last_loaded = time.time()
|
||||
logger.info(f"Persisted {len(self._gemini_keys)} Gemini key(s) to {self._env_path}")
|
||||
|
||||
def reload_from_env(self):
|
||||
"""Force reload from .env (e.g., if edited externally)."""
|
||||
with self._keys_lock:
|
||||
self._load_from_env()
|
||||
logger.info(f"Reloaded {len(self._gemini_keys)} Gemini key(s) from .env")
|
||||
return len(self._gemini_keys)
|
||||
|
||||
|
||||
# Module-level convenience — import and use anywhere
|
||||
_manager = None
|
||||
|
||||
def get_key_manager():
|
||||
"""Get the singleton KeyManager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = KeyManager()
|
||||
return _manager
|
||||
Loading…
Add table
Add a link
Reference in a new issue