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:
Matt Johnson 2026-04-13 06:02:16 +00:00
commit e9231ac24a
93 changed files with 51223 additions and 254 deletions

696
assets/echo6-custom.css Normal file
View 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
*/

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/echo6_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

270
assets/key_manager.py Normal file
View 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