8126 lines
303 KiB
Plaintext
8126 lines
303 KiB
Plaintext
<!doctype html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>SAMI-CLOUD Status</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||
<meta http-equiv="Pragma" content="no-cache" />
|
||
<meta http-equiv="Expires" content="0" />
|
||
|
||
<link rel="icon" href="/assets/favicon.ico" sizes="any">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
|
||
<link rel="icon" type="image/png" sizes="512x512" href="/assets/icon-512.png">
|
||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
|
||
<link rel="manifest" href="/assets/site.webmanifest">
|
||
<link rel="stylesheet" href="/assets/fonts.css">
|
||
<link rel="preload" href="/assets/icons.svg" as="image" type="image/svg+xml">
|
||
<meta name="theme-color" content="#0e1116">
|
||
|
||
<style>
|
||
/* Sami Grotesk Custom Font Family */
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf') format('truetype');
|
||
font-weight: 300;
|
||
font-style: normal;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf') format('truetype');
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf') format('truetype');
|
||
font-weight: 500;
|
||
font-style: normal;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf') format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf') format('truetype');
|
||
font-weight: 900;
|
||
font-style: normal;
|
||
font-display: swap;
|
||
}
|
||
|
||
/* Italic variants */
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf') format('truetype');
|
||
font-weight: 400;
|
||
font-style: italic;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf') format('truetype');
|
||
font-weight: 500;
|
||
font-style: italic;
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Sami Grotesk';
|
||
src: url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf') format('truetype');
|
||
font-weight: 700;
|
||
font-style: italic;
|
||
font-display: swap;
|
||
}
|
||
|
||
:root {
|
||
/* ==== Brand accent system ==== */
|
||
--accent: #8FD6FF;
|
||
--accent-strong: #1F7BFF;
|
||
|
||
/* Dark theme (default) — richer & shinier */
|
||
--bg: #0b0f1a;
|
||
--fg: #e8ecf5;
|
||
--muted: #9aa6bf;
|
||
--card-base: #121826;
|
||
--border: #263552;
|
||
|
||
--ok-bg: #0c2430;
|
||
--ok-fg: #7ef2ff;
|
||
--bad-bg: #2a121a;
|
||
--bad-fg: #ff9aa3;
|
||
--dot-ok: #35d1ff;
|
||
--dot-bad: #ff5f7a;
|
||
|
||
/* Bigger logo than before */
|
||
--brand-min: 140px;
|
||
--brand-max: 320px;
|
||
--brand-h: clamp(var(--brand-min), 22vw, var(--brand-max));
|
||
--radius: 12px;
|
||
}
|
||
|
||
/* Light theme */
|
||
:root.light {
|
||
--bg: #f6f7fb;
|
||
--fg: #0f1115;
|
||
--muted: #5f6b7a;
|
||
--card-base: #ffffff;
|
||
--border: #e2e7ef;
|
||
|
||
--ok-bg: #eafff1;
|
||
--ok-fg: #0a7c3a;
|
||
--bad-bg: #ffefef;
|
||
--bad-fg: #b00020;
|
||
--dot-ok: #0fb15a;
|
||
--dot-bad: #d93b3b;
|
||
}
|
||
|
||
/* Blue theme (deep royal #1908AC) */
|
||
:root.blue {
|
||
--bg: #1908AC;
|
||
--fg: #e8f1ff;
|
||
--muted: #d6e2ff;
|
||
--card-base: #0d1533;
|
||
--border: #1c2d6a;
|
||
|
||
--ok-bg: rgba(255, 255, 255, .14);
|
||
--ok-fg: #edffff;
|
||
--bad-bg: rgba(0, 0, 0, .18);
|
||
--bad-fg: #ffb3c0;
|
||
--dot-ok: #c7e5ff;
|
||
--dot-bad: #ffd6dc;
|
||
|
||
--accent: #9cd4ff;
|
||
--accent-strong: #6fb2ff;
|
||
}
|
||
|
||
/* Nord theme */
|
||
:root.nord {
|
||
--bg: #2e3440;
|
||
--fg: #eceff4;
|
||
--muted: #81a1c1;
|
||
--card-base: #3b4252;
|
||
--border: #4c566a;
|
||
|
||
--ok-bg: #2d4f3e;
|
||
--ok-fg: #a3be8c;
|
||
--bad-bg: #4a2c2a;
|
||
--bad-fg: #bf616a;
|
||
--dot-ok: #a3be8c;
|
||
--dot-bad: #bf616a;
|
||
|
||
--accent: #88c0d0;
|
||
--accent-strong: #5e81ac;
|
||
}
|
||
|
||
/* Dracula theme */
|
||
:root.dracula {
|
||
--bg: #282a36;
|
||
--fg: #f8f8f2;
|
||
--muted: #6272a4;
|
||
--card-base: #44475a;
|
||
--border: #6272a4;
|
||
|
||
--ok-bg: #1e3a2e;
|
||
--ok-fg: #50fa7b;
|
||
--bad-bg: #3d1a1a;
|
||
--bad-fg: #ff5555;
|
||
--dot-ok: #50fa7b;
|
||
--dot-bad: #ff5555;
|
||
|
||
--accent: #bd93f9;
|
||
--accent-strong: #8be9fd;
|
||
}
|
||
|
||
/* Solarized Dark theme */
|
||
:root.solarized-dark {
|
||
--bg: #002b36;
|
||
--fg: #839496;
|
||
--muted: #586e75;
|
||
--card-base: #073642;
|
||
--border: #586e75;
|
||
|
||
--ok-bg: #0d3d2c;
|
||
--ok-fg: #859900;
|
||
--bad-bg: #3d1a1a;
|
||
--bad-fg: #dc322f;
|
||
--dot-ok: #859900;
|
||
--dot-bad: #dc322f;
|
||
|
||
--accent: #268bd2;
|
||
--accent-strong: #2aa198;
|
||
}
|
||
|
||
/* Solarized Light theme */
|
||
:root.solarized-light {
|
||
--bg: #fdf6e3;
|
||
--fg: #657b83;
|
||
--muted: #93a1a1;
|
||
--card-base: #eee8d5;
|
||
--border: #93a1a1;
|
||
|
||
--ok-bg: #e8f5e8;
|
||
--ok-fg: #859900;
|
||
--bad-bg: #fdf2f2;
|
||
--bad-fg: #dc322f;
|
||
--dot-ok: #859900;
|
||
--dot-bad: #dc322f;
|
||
|
||
--accent: #268bd2;
|
||
--accent-strong: #2aa198;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box
|
||
}
|
||
|
||
/* Smooth theme transitions */
|
||
:root {
|
||
transition:
|
||
--bg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--fg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--muted 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--card-base 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--border 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--ok-bg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--ok-fg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--bad-bg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--bad-fg 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--dot-ok 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--dot-bad 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--accent 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
--accent-strong 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
body, .card, button, .badge, .logo-wrap, .weather-modal-content {
|
||
transition:
|
||
background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
html,
|
||
body {
|
||
height: 100%
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 24px;
|
||
background:
|
||
radial-gradient(1200px 900px at 8% -12%, rgba(96, 128, 255, .18), transparent 60%),
|
||
radial-gradient(1000px 700px at 110% -10%, rgba(150, 60, 255, .12), transparent 55%),
|
||
radial-gradient(900px 650px at 50% 120%, rgba(30, 60, 160, .16), transparent 60%),
|
||
var(--bg);
|
||
color: var(--fg);
|
||
font: 16px/1.5 'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
}
|
||
|
||
:root.light body {
|
||
background:
|
||
radial-gradient(1200px 800px at 10% -10%, rgba(90, 120, 255, .1), transparent 60%),
|
||
radial-gradient(1000px 700px at 110% 10%, rgba(255, 120, 200, .08), transparent 55%),
|
||
var(--bg);
|
||
}
|
||
|
||
:root.blue body {
|
||
background:
|
||
radial-gradient(1200px 820px at 8% -10%, rgba(255, 255, 255, 0.06), transparent 60%),
|
||
radial-gradient(1000px 700px at 110% 0%, rgba(0, 0, 0, 0.12), transparent 55%),
|
||
radial-gradient(900px 650px at 50% 120%, rgba(15, 0, 60, 0.12), transparent 60%),
|
||
#1908AC;
|
||
}
|
||
|
||
/* Optional: faint watermark */
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: auto auto 6% 50%;
|
||
translate: -50% 0;
|
||
width: min(60vw, 900px);
|
||
aspect-ratio: 3 / 1;
|
||
background: url(/assets/SAMI-CLOUD.png) center/contain no-repeat;
|
||
opacity: .06;
|
||
filter: blur(.4px);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.muted {
|
||
color: var(--muted)
|
||
}
|
||
|
||
button {
|
||
padding: .38rem .75rem;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--fg);
|
||
cursor: pointer;
|
||
font-size: .88rem;
|
||
font-family: 'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
transition: background-color .15s ease, border-color .15s ease, transform .12s ease, box-shadow .15s ease, backdrop-filter .15s ease;
|
||
}
|
||
|
||
/* (1) Glassy button hover effects */
|
||
button:hover {
|
||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||
backdrop-filter: blur(4px) saturate(160%);
|
||
box-shadow: 0 0 4px rgba(255, 255, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%);
|
||
}
|
||
|
||
:root.light button:hover {
|
||
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
|
||
border-color: rgba(0, 0, 0, .15);
|
||
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
|
||
}
|
||
|
||
:root.blue button:hover {
|
||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||
border-color: rgba(255, 255, 255, .35);
|
||
box-shadow: 0 0 6px rgba(255, 255, 255, .22), inset 0 1px 0 rgba(255, 255, 255, .18);
|
||
}
|
||
|
||
button:active {
|
||
transform: translateY(1px)
|
||
}
|
||
|
||
button:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent);
|
||
}
|
||
|
||
/* ===== Top bar: logo and weather on top row, tools below ===== */
|
||
.bar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin: 0 0 8px;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.top-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 24px;
|
||
}
|
||
|
||
.tools-row {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
margin-left: 0;
|
||
}
|
||
|
||
#brand {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 12px;
|
||
min-height: calc(var(--brand-h) + 4px);
|
||
}
|
||
|
||
#brand img {
|
||
height: var(--brand-h);
|
||
width: auto;
|
||
max-width: 70vw;
|
||
display: block;
|
||
filter: drop-shadow(0 3px 10px rgba(0, 0, 0, .38));
|
||
}
|
||
|
||
/* Weather widget positioned directly right of logo */
|
||
.weather-widget-container {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
margin-bottom: 0;
|
||
max-width: 300px;
|
||
flex-shrink: 1;
|
||
padding-top: 10px;
|
||
}
|
||
|
||
.tools {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center
|
||
}
|
||
|
||
.tools .chip {
|
||
font-size: .8rem;
|
||
padding: .3rem .55rem;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border);
|
||
background: transparent
|
||
}
|
||
|
||
/* Weather Widget - Large, no background, same line as logo */
|
||
.weather-widget {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 16px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
backdrop-filter: none;
|
||
position: relative;
|
||
min-width: 0;
|
||
box-shadow: none;
|
||
margin-bottom: 0;
|
||
height: var(--brand-h);
|
||
max-width: 100%;
|
||
}
|
||
|
||
.weather-content {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 16px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: calc(var(--brand-h) * 0.6);
|
||
line-height: 1;
|
||
filter: drop-shadow(0 3px 12px rgba(0, 0, 0, .4));
|
||
align-self: flex-end;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.weather-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
align-items: flex-start;
|
||
justify-content: flex-end;
|
||
height: 100%;
|
||
flex: 1;
|
||
}
|
||
|
||
.weather-location {
|
||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||
color: var(--muted);
|
||
line-height: 1.2;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
overflow: visible;
|
||
text-overflow: clip;
|
||
max-width: 100%;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.weather-temp {
|
||
font-weight: 800;
|
||
font-size: clamp(1.5rem, 2.5vw, 2.5rem);
|
||
line-height: 1;
|
||
text-shadow: 0 2px 4px rgba(0, 0, 0, .4);
|
||
color: var(--fg);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:root.light .weather-temp {
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .2);
|
||
}
|
||
|
||
.weather-condition {
|
||
font-size: clamp(0.75rem, 0.9vw, 0.9rem);
|
||
color: var(--fg);
|
||
line-height: 1.2;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.weather-wind {
|
||
font-size: clamp(0.7rem, 0.85vw, 0.85rem);
|
||
color: var(--muted);
|
||
line-height: 1.2;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.weather-settings-btn {
|
||
padding: 6px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-size: 1.2rem;
|
||
transition: all .15s ease;
|
||
opacity: .6;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.weather-settings-btn:hover {
|
||
background: rgba(255, 255, 255, .1);
|
||
color: var(--fg);
|
||
opacity: 1;
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
/* Weather settings button at bottom */
|
||
.weather-settings-btn-bottom {
|
||
padding: 4px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
transition: all .15s ease;
|
||
opacity: .7;
|
||
margin-top: 4px;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.weather-settings-btn-bottom:hover {
|
||
background: rgba(255, 255, 255, .1);
|
||
color: var(--fg);
|
||
opacity: 1;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* Weather Settings Modal */
|
||
.weather-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, .6);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.weather-modal.show {
|
||
display: flex
|
||
}
|
||
|
||
.weather-modal-content {
|
||
background: var(--card-base);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 24px;
|
||
min-width: 300px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, .4);
|
||
position: relative;
|
||
resize: both;
|
||
overflow: auto;
|
||
min-height: 200px;
|
||
max-width: 90vw;
|
||
max-height: 90vh;
|
||
}
|
||
|
||
/* Draggable Dialog Enhancements */
|
||
.draggable-dialog {
|
||
position: fixed !important;
|
||
z-index: 1000;
|
||
background: var(--card-base);
|
||
border: 2px solid var(--accent);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, .8);
|
||
resize: both;
|
||
overflow: auto;
|
||
min-width: 300px;
|
||
min-height: 200px;
|
||
max-width: 95vw;
|
||
max-height: 95vh;
|
||
}
|
||
|
||
.dialog-header {
|
||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||
color: var(--bg);
|
||
padding: 12px 16px;
|
||
margin: -24px -24px 16px -24px;
|
||
cursor: move;
|
||
user-select: none;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
}
|
||
|
||
.dialog-controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.dialog-btn {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border: none;
|
||
color: var(--bg);
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.dialog-btn:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.dialog-resize-handle {
|
||
position: absolute;
|
||
bottom: 0;
|
||
right: 0;
|
||
width: 20px;
|
||
height: 20px;
|
||
cursor: se-resize;
|
||
background: linear-gradient(-45deg, transparent 0%, transparent 40%, var(--accent) 50%, transparent 60%, transparent 100%);
|
||
}
|
||
|
||
/* Make all modals draggable */
|
||
.logs-modal .logs-modal-content,
|
||
.weather-modal .weather-modal-content,
|
||
#add-service-modal .weather-modal-content,
|
||
#app-selector-modal .app-selector-content {
|
||
position: fixed;
|
||
resize: both;
|
||
overflow: auto;
|
||
min-width: 300px;
|
||
min-height: 200px;
|
||
max-width: 95vw;
|
||
max-height: 95vh;
|
||
}
|
||
|
||
/* Add Service Modal - Ultra Compact */
|
||
#add-service-modal {
|
||
position: fixed !important;
|
||
top: 50% !important;
|
||
left: 50% !important;
|
||
transform: translate(-50%, -50%) !important;
|
||
width: 750px !important;
|
||
max-width: 95vw !important;
|
||
max-height: 92vh !important;
|
||
background: var(--card-base) !important;
|
||
z-index: 1000 !important;
|
||
display: none !important;
|
||
overflow-y: auto !important;
|
||
border: 2px solid var(--accent) !important;
|
||
border-radius: 8px !important;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, .8) !important;
|
||
}
|
||
|
||
#add-service-modal.show {
|
||
display: block !important;
|
||
}
|
||
|
||
#add-service-modal .weather-modal-content {
|
||
width: 100% !important;
|
||
margin: 0 !important;
|
||
padding: 14px !important;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
}
|
||
|
||
#add-service-modal::-webkit-scrollbar {
|
||
width: 8px !important;
|
||
}
|
||
|
||
#add-service-modal::-webkit-scrollbar-track {
|
||
background: var(--bg) !important;
|
||
}
|
||
|
||
#add-service-modal::-webkit-scrollbar-thumb {
|
||
background: var(--accent) !important;
|
||
border-radius: 4px !important;
|
||
}
|
||
|
||
#add-service-modal::-webkit-scrollbar-thumb:hover {
|
||
background: var(--accent-strong) !important;
|
||
}
|
||
|
||
/* Make form elements ultra compact */
|
||
#add-service-modal input,
|
||
#add-service-modal select,
|
||
#add-service-modal textarea {
|
||
font-size: 0.75rem !important;
|
||
padding: 4px 6px !important;
|
||
margin-bottom: 3px !important;
|
||
line-height: 1.2 !important;
|
||
}
|
||
|
||
#add-service-modal label {
|
||
font-size: 0.7rem !important;
|
||
margin-bottom: 2px !important;
|
||
display: block !important;
|
||
line-height: 1.2 !important;
|
||
}
|
||
|
||
#add-service-modal h3 {
|
||
font-size: 0.95rem !important;
|
||
margin: 0 0 8px !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
|
||
#add-service-modal h4 {
|
||
font-size: 0.8rem !important;
|
||
margin: 6px 0 4px !important;
|
||
padding-top: 4px !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
|
||
#add-service-modal h4:first-of-type {
|
||
padding-top: 0 !important;
|
||
margin-top: 0 !important;
|
||
}
|
||
|
||
/* Compact sections */
|
||
#add-service-modal .weather-modal-content > div {
|
||
gap: 6px !important;
|
||
}
|
||
|
||
#add-service-modal p {
|
||
font-size: 0.7rem !important;
|
||
margin: 2px 0 4px !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
|
||
#add-service-modal details {
|
||
margin-top: 6px !important;
|
||
}
|
||
|
||
#add-service-modal details summary {
|
||
font-size: 0.75rem !important;
|
||
padding: 3px 0 !important;
|
||
}
|
||
|
||
#add-service-modal details > div {
|
||
margin-top: 4px !important;
|
||
gap: 6px !important;
|
||
}
|
||
|
||
/* Quick IP selection buttons - compact */
|
||
.quick-ip-btn {
|
||
padding: 3px 8px !important;
|
||
font-size: 0.7rem !important;
|
||
background: color-mix(in srgb, var(--accent) 15%, transparent) !important;
|
||
border: 1px solid var(--accent) !important;
|
||
border-radius: 3px !important;
|
||
color: var(--accent) !important;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
white-space: nowrap;
|
||
line-height: 1.2 !important;
|
||
}
|
||
|
||
.quick-ip-btn:hover {
|
||
background: color-mix(in srgb, var(--accent) 30%, transparent) !important;
|
||
}
|
||
|
||
.quick-ip-btn.active {
|
||
background: var(--accent) !important;
|
||
color: var(--bg) !important;
|
||
}
|
||
|
||
.quick-ip-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Compact modal buttons */
|
||
#add-service-modal button {
|
||
padding: 5px 10px !important;
|
||
font-size: 0.75rem !important;
|
||
line-height: 1.2 !important;
|
||
}
|
||
|
||
#add-service-modal .weather-modal-buttons {
|
||
margin-top: 10px !important;
|
||
gap: 6px !important;
|
||
}
|
||
|
||
/* Compact grid spacing */
|
||
#add-service-modal div[style*="grid"] {
|
||
gap: 5px !important;
|
||
}
|
||
|
||
#add-service-modal .quick-ip-buttons {
|
||
gap: 4px !important;
|
||
margin-top: 3px !important;
|
||
}
|
||
|
||
.weather-modal h3 {
|
||
margin: 0 0 16px;
|
||
color: var(--fg)
|
||
}
|
||
|
||
.weather-modal label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: var(--fg);
|
||
font-size: .9rem
|
||
}
|
||
|
||
.weather-modal input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font-size: .9rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.weather-modal-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end
|
||
}
|
||
|
||
.weather-modal button {
|
||
margin: 0
|
||
}
|
||
|
||
/* Setup Wizard Styles */
|
||
.setup-wizard {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, .85);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.setup-wizard-content {
|
||
background: var(--card-base);
|
||
border: 2px solid var(--accent);
|
||
border-radius: 16px;
|
||
padding: 40px;
|
||
min-width: 600px;
|
||
max-width: 800px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 80px rgba(0, 0, 0, .6);
|
||
}
|
||
|
||
.setup-step {
|
||
animation: fadeIn 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.setup-options {
|
||
display: grid;
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.setup-option {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding: 20px;
|
||
background: var(--card-bg);
|
||
border: 2px solid var(--border);
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.setup-option:hover {
|
||
border-color: var(--accent);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
||
}
|
||
|
||
.setup-option input[type="radio"] {
|
||
margin-right: 16px;
|
||
margin-top: 4px;
|
||
width: 20px;
|
||
height: 20px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.setup-option input[type="radio"]:checked ~ .setup-option-content {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.setup-option:has(input:checked) {
|
||
border-color: var(--accent);
|
||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||
}
|
||
|
||
.setup-option-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.setup-option-icon {
|
||
font-size: 2rem;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.setup-option-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.setup-option-desc {
|
||
font-size: 0.9rem;
|
||
color: var(--muted);
|
||
line-height: 1.5;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.setup-option-example {
|
||
font-size: 0.85rem;
|
||
color: var(--accent);
|
||
font-family: monospace;
|
||
padding: 6px 10px;
|
||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.setup-wizard-buttons {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 32px;
|
||
align-items: center;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.setup-wizard-buttons button {
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
border: 2px solid var(--border);
|
||
background: var(--card-bg);
|
||
color: var(--fg);
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
z-index: 11;
|
||
}
|
||
|
||
.setup-wizard-buttons button:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, .2);
|
||
}
|
||
|
||
.setup-btn-primary {
|
||
background: color-mix(in srgb, var(--accent) 20%, transparent) !important;
|
||
border-color: var(--accent) !important;
|
||
color: var(--accent) !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
|
||
.setup-btn-primary:hover {
|
||
background: color-mix(in srgb, var(--accent) 30%, transparent) !important;
|
||
}
|
||
|
||
/* App Selector Modal - No backdrop, positioned in corner */
|
||
#app-selector-modal {
|
||
background: transparent;
|
||
backdrop-filter: none;
|
||
align-items: flex-start;
|
||
justify-content: flex-end;
|
||
padding: 20px;
|
||
}
|
||
|
||
#app-selector-modal.show {
|
||
display: flex;
|
||
}
|
||
|
||
.app-selector-content {
|
||
background: var(--card-base);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 24px;
|
||
width: 600px;
|
||
max-width: calc(100vw - 40px);
|
||
max-height: calc(100vh - 40px);
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, .6), 0 0 0 1px rgba(255, 255, 255, .08);
|
||
backdrop-filter: blur(20px) saturate(150%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||
}
|
||
|
||
.app-selector-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.app-option {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .01));
|
||
cursor: pointer;
|
||
transition: all .2s ease;
|
||
text-align: center;
|
||
}
|
||
|
||
.app-option:hover {
|
||
transform: translateY(-4px);
|
||
border-color: var(--accent);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .10), rgba(255, 255, 255, .04));
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, .3), 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||
}
|
||
|
||
.app-option-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 8px;
|
||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .3));
|
||
}
|
||
|
||
.app-option-name {
|
||
font-size: .9rem;
|
||
font-weight: 600;
|
||
color: var(--fg);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.app-option-desc {
|
||
font-size: .75rem;
|
||
color: var(--muted);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.app-category-header {
|
||
grid-column: 1 / -1;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
color: var(--accent-strong);
|
||
margin-top: 16px;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
|
||
@media (max-width: 800px) {
|
||
#app-selector-modal {
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.app-selector-content {
|
||
width: calc(100vw - 32px);
|
||
max-height: calc(100vh - 32px);
|
||
padding: 20px 16px;
|
||
}
|
||
|
||
.app-selector-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.app-option {
|
||
padding: 12px 6px;
|
||
}
|
||
|
||
.app-option-icon {
|
||
font-size: 32px;
|
||
}
|
||
|
||
.app-option-name {
|
||
font-size: .85rem;
|
||
}
|
||
|
||
.app-option-desc {
|
||
font-size: .7rem;
|
||
}
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg)
|
||
}
|
||
}
|
||
|
||
.brand-spinner {
|
||
width: 18px;
|
||
height: 18px;
|
||
display: inline-block;
|
||
vertical-align: -3px;
|
||
margin-right: 8px;
|
||
mask: url(/assets/SAMI-CLOUD.png) center/contain no-repeat;
|
||
-webkit-mask: url(/assets/SAMI-CLOUD.png) center/contain no-repeat;
|
||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||
animation: spin 1.2s linear infinite;
|
||
}
|
||
|
||
/* ---------- TOP ANCHOR ROW (DNS/Internet) ---------- */
|
||
.top {
|
||
display: grid;
|
||
gap: 16px;
|
||
margin: 16px 0 24px;
|
||
grid-template-columns: repeat(3, minmax(280px, 1fr));
|
||
align-items: stretch;
|
||
position: relative;
|
||
}
|
||
|
||
.top::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: -10px;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent 0%, var(--accent) 25%, var(--accent-strong) 50%, var(--accent) 75%, transparent 100%);
|
||
opacity: .35;
|
||
filter: blur(.2px);
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.top {
|
||
grid-template-columns: repeat(2, minmax(260px, 1fr))
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.top {
|
||
grid-template-columns: 1fr
|
||
}
|
||
}
|
||
|
||
.top .card {
|
||
width: auto;
|
||
min-width: 0;
|
||
min-height: 200px;
|
||
padding-bottom: 48px;
|
||
}
|
||
|
||
.top .row .name {
|
||
font-size: clamp(20px, 1.4vw + 18px, 26px)
|
||
}
|
||
|
||
/* ---------- APP GRID ---------- */
|
||
.grid {
|
||
display: grid;
|
||
gap: 14px;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
align-items: stretch;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* === Glass cards === */
|
||
.card {
|
||
position: relative;
|
||
background:
|
||
linear-gradient(180deg, rgba(180, 200, 255, .10), rgba(180, 200, 255, .03)),
|
||
rgba(18, 24, 38, .42);
|
||
border: 1px solid rgba(120, 170, 255, .22);
|
||
border-radius: var(--radius);
|
||
padding: 14px 16px 60px;
|
||
min-height: 180px;
|
||
box-shadow:
|
||
0 10px 34px rgba(0, 10, 40, .55),
|
||
inset 0 1px 0 rgba(200, 220, 255, .14);
|
||
backdrop-filter: blur(12px) saturate(135%);
|
||
-webkit-backdrop-filter: blur(12px) saturate(135%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: .6rem;
|
||
transition:
|
||
box-shadow .18s ease,
|
||
border-color .18s ease,
|
||
background-color .18s ease,
|
||
backdrop-filter .18s ease,
|
||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
padding-bottom: 44px;
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
|
||
/* Staggered loading animation */
|
||
.card.loaded {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.grid .card {
|
||
min-width: 300px;
|
||
width: clamp(300px, 30vw, 380px);
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* Sheen animation (all themes) */
|
||
@keyframes sheen {
|
||
from {
|
||
background-position: -200% 0
|
||
}
|
||
|
||
to {
|
||
background-position: 200% 0
|
||
}
|
||
}
|
||
|
||
.card::before,
|
||
.card::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: var(--radius);
|
||
pointer-events: none
|
||
}
|
||
|
||
.card::before {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 28%);
|
||
mix-blend-mode: screen;
|
||
opacity: .55;
|
||
}
|
||
|
||
.card:hover::before {
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 28%),
|
||
linear-gradient(120deg, rgba(255, 255, 255, .25) 8%, rgba(255, 255, 255, 0) 22%, rgba(255, 255, 255, .18) 36%, rgba(255, 255, 255, 0) 52%);
|
||
background-size: 100% 100%, 220% 220%;
|
||
animation: sheen 2s ease-in-out infinite;
|
||
opacity: .5;
|
||
}
|
||
|
||
.card::after {
|
||
background:
|
||
radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, .06), transparent 60%),
|
||
radial-gradient(1px 1px at 70% 60%, rgba(255, 255, 255, .05), transparent 60%),
|
||
radial-gradient(1px 1px at 40% 80%, rgba(255, 255, 255, .04), transparent 60%);
|
||
opacity: .35;
|
||
filter: saturate(130%);
|
||
}
|
||
|
||
/* (2) Floating motion on hover */
|
||
@keyframes float {
|
||
|
||
0%,
|
||
100% {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
50% {
|
||
transform: translateY(-6px);
|
||
}
|
||
}
|
||
|
||
.card:hover {
|
||
animation: float 3.5s ease-in-out infinite;
|
||
}
|
||
|
||
/* (3) Animated border pulse (subtle) */
|
||
@keyframes borderPulse {
|
||
0% {
|
||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, rgba(192, 192, 192, 0.25)), 0 6px 24px color-mix(in srgb, var(--accent-strong) 12%, rgba(255, 255, 255, 0.12)), 0 0 18px 4px color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.10)), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
50% {
|
||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, rgba(192, 192, 192, 0.35)), 0 10px 28px color-mix(in srgb, var(--accent-strong) 18%, rgba(255, 255, 255, 0.16)), 0 0 24px 6px color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.14)), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
100% {
|
||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, rgba(192, 192, 192, 0.25)), 0 6px 24px color-mix(in srgb, var(--accent-strong) 12%, rgba(255, 255, 255, 0.12)), 0 0 18px 4px color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.10)), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||
}
|
||
}
|
||
|
||
.card:hover {
|
||
animation-name: float, borderPulse;
|
||
animation-duration: 3.5s, 2.2s;
|
||
animation-timing-function: ease-in-out, ease-in-out;
|
||
animation-iteration-count: infinite, infinite;
|
||
}
|
||
|
||
/* Content rows */
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .6rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.row .name {
|
||
font-weight: 700;
|
||
font-size: clamp(18px, 1.1vw + 16px, 24px);
|
||
line-height: 1.25;
|
||
letter-spacing: .1px;
|
||
text-shadow: 0 1px 1px rgba(0, 0, 0, .35);
|
||
flex: 1 1 140px;
|
||
min-width: 0;
|
||
white-space: normal;
|
||
word-break: normal;
|
||
overflow-wrap: anywhere;
|
||
hyphens: auto;
|
||
}
|
||
|
||
:root.light .row .name {
|
||
text-shadow: none
|
||
}
|
||
|
||
.row .spacer {
|
||
margin-left: auto
|
||
}
|
||
|
||
.dot {
|
||
width: .75rem;
|
||
height: .75rem;
|
||
border-radius: 50%;
|
||
position: relative;
|
||
box-shadow: 0 0 0 2px rgba(0, 0, 0, .25), 0 0 10px rgba(0, 0, 0, .35) inset
|
||
}
|
||
|
||
.dot.ok {
|
||
background: var(--dot-ok)
|
||
}
|
||
|
||
.dot.bad {
|
||
background: var(--dot-bad);
|
||
animation: pulse-bad 2.4s ease-out infinite
|
||
}
|
||
|
||
@keyframes pulse-bad {
|
||
0% {
|
||
box-shadow: 0 0 0 0 rgba(255, 103, 103, .35)
|
||
}
|
||
|
||
70% {
|
||
box-shadow: 0 0 0 8px rgba(255, 103, 103, 0)
|
||
}
|
||
|
||
100% {
|
||
box-shadow: 0 0 0 0 rgba(255, 103, 103, 0)
|
||
}
|
||
}
|
||
|
||
.at-bl {
|
||
position: absolute;
|
||
bottom: 12px;
|
||
left: 16px
|
||
}
|
||
|
||
/* Glow dot when hovering Open button */
|
||
.card:has(.btn-row:hover) .dot.at-bl {
|
||
box-shadow:
|
||
0 0 0 2px rgba(0, 0, 0, 0.25),
|
||
0 0 10px rgba(0, 0, 0, 0.35) inset,
|
||
0 0 12px 4px currentColor;
|
||
transition: box-shadow .2s ease;
|
||
}
|
||
|
||
.card:has(.btn-row:hover) .dot.ok {
|
||
background-color: color-mix(in srgb, var(--dot-ok) 90%, white 10%)
|
||
}
|
||
|
||
.card:has(.btn-row:hover) .dot.bad {
|
||
background-color: color-mix(in srgb, var(--dot-bad) 90%, white 10%)
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
font-weight: 700;
|
||
padding: 6px 12px 6px 28px;
|
||
border-radius: 999px;
|
||
position: relative;
|
||
letter-spacing: .25px;
|
||
box-shadow: 0 1px 0 rgba(255, 255, 255, .06) inset;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
filter: drop-shadow(0 0 6px currentColor);
|
||
opacity: .9
|
||
}
|
||
|
||
.badge.on {
|
||
background: color-mix(in srgb, var(--ok-bg) 80%, var(--accent) 20%);
|
||
color: var(--ok-fg)
|
||
}
|
||
|
||
.badge.off {
|
||
background: color-mix(in srgb, var(--bad-bg) 85%, var(--accent-strong) 15%);
|
||
color: var(--bad-fg)
|
||
}
|
||
|
||
.logo-wrap {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 14px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .01));
|
||
border: 1px solid color-mix(in oklab, var(--border) 60%, #fff 3%);
|
||
display: grid;
|
||
place-items: center;
|
||
flex: 0 0 64px;
|
||
}
|
||
|
||
:root.light .logo-wrap {
|
||
background: linear-gradient(180deg, rgba(0, 0, 0, .03), rgba(0, 0, 0, .01));
|
||
border: 1px solid rgba(0, 0, 0, .06)
|
||
}
|
||
|
||
:root.blue .logo-wrap {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, .04));
|
||
border: 1px solid rgba(255, 255, 255, .35)
|
||
}
|
||
|
||
/* Logo and Icon Container */
|
||
.logo-wrap {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 14px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .01));
|
||
border: 1px solid color-mix(in oklab, var(--border) 60%, #fff 3%);
|
||
display: grid;
|
||
place-items: center;
|
||
flex: 0 0 64px;
|
||
}
|
||
|
||
:root.light .logo-wrap {
|
||
background: linear-gradient(180deg, rgba(0, 0, 0, .03), rgba(0, 0, 0, .01));
|
||
border: 1px solid rgba(0, 0, 0, .06)
|
||
}
|
||
|
||
:root.blue .logo-wrap {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, .04));
|
||
border: 1px solid rgba(255, 255, 255, .35)
|
||
}
|
||
|
||
/* PNG Logo Images - Reliable and Fast */
|
||
.logo-img {
|
||
width: 54px;
|
||
height: 54px;
|
||
object-fit: contain;
|
||
display: block;
|
||
transition: all .2s ease;
|
||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .3));
|
||
}
|
||
|
||
.card:hover .logo-img {
|
||
transform: scale(1.1);
|
||
filter: drop-shadow(0 3px 8px rgba(0, 0, 0, .4));
|
||
}
|
||
|
||
.card.loaded .logo-img {
|
||
animation: iconFadeIn 0.4s ease-out 0.2s both;
|
||
}
|
||
|
||
@keyframes iconFadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.8);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
/* DNS and Internet icons in top row */
|
||
.top .logo-img {
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
|
||
|
||
.btn-row {
|
||
position: absolute;
|
||
right: 12px;
|
||
bottom: 12px
|
||
}
|
||
|
||
.btn-row button {
|
||
opacity: .95;
|
||
transition: opacity .15s ease, transform .15s
|
||
}
|
||
|
||
.card:hover .btn-row button {
|
||
opacity: 1;
|
||
transform: translateY(-1px)
|
||
}
|
||
|
||
/* Logs button styling */
|
||
.logs-btn {
|
||
margin-left: 8px !important;
|
||
font-size: .8rem !important;
|
||
padding: .3rem .6rem !important;
|
||
}
|
||
|
||
/* Delete button styling */
|
||
.delete-btn {
|
||
margin-right: 8px !important;
|
||
font-size: .8rem !important;
|
||
padding: .3rem .5rem !important;
|
||
background: color-mix(in srgb, var(--bad-fg) 10%, transparent) !important;
|
||
border-color: color-mix(in srgb, var(--bad-fg) 40%, var(--border)) !important;
|
||
color: var(--bad-fg) !important;
|
||
opacity: 0.7;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.delete-btn:hover {
|
||
background: color-mix(in srgb, var(--bad-fg) 25%, transparent) !important;
|
||
border-color: var(--bad-fg) !important;
|
||
opacity: 1;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* Settings button styling */
|
||
.settings-btn {
|
||
margin-left: 4px !important;
|
||
font-size: .8rem !important;
|
||
padding: .3rem .5rem !important;
|
||
min-width: auto !important;
|
||
}
|
||
|
||
/* Options button styling */
|
||
.options-btn {
|
||
margin-right: 8px !important;
|
||
font-size: .8rem !important;
|
||
padding: .3rem .5rem !important;
|
||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border)) !important;
|
||
color: var(--accent) !important;
|
||
opacity: 0.7;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.options-btn:hover {
|
||
background: color-mix(in srgb, var(--accent) 25%, transparent) !important;
|
||
border-color: var(--accent) !important;
|
||
opacity: 1;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* Internet card packet blink */
|
||
.card[data-app="internet"] .dot {
|
||
transition: all 0.1s ease;
|
||
}
|
||
.card[data-app="internet"] .dot.packet-rx {
|
||
box-shadow: 0 0 8px 2px #4caf50;
|
||
background: #4caf50;
|
||
}
|
||
.card[data-app="internet"] .dot.packet-tx {
|
||
box-shadow: 0 0 8px 2px #2196f3;
|
||
background: #2196f3;
|
||
}
|
||
|
||
/* Restart button styling */
|
||
.restart-btn {
|
||
margin-right: 8px !important;
|
||
font-size: .8rem !important;
|
||
padding: .3rem .6rem !important;
|
||
}
|
||
|
||
/* Token Management Modal Styles */
|
||
.token-section {
|
||
background: color-mix(in srgb, var(--accent) 5%, var(--card-base) 95%);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.token-section-title {
|
||
margin: 0 0 10px;
|
||
color: var(--accent);
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.token-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.token-field label {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.token-input-row {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.token-input-row input {
|
||
flex: 1;
|
||
font-size: 0.85rem;
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
.token-toggle {
|
||
padding: 6px 10px !important;
|
||
min-width: auto !important;
|
||
font-size: 0.8rem !important;
|
||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
|
||
.token-toggle:hover {
|
||
background: color-mix(in srgb, var(--accent) 20%, transparent) !important;
|
||
}
|
||
|
||
.token-status {
|
||
margin-top: 8px;
|
||
font-size: 0.75rem;
|
||
min-height: 18px;
|
||
}
|
||
|
||
.token-status.success {
|
||
color: var(--ok-fg);
|
||
}
|
||
|
||
.token-status.error {
|
||
color: var(--bad-fg);
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.token-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* DNS Logs Modal */
|
||
.logs-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, .7);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1001;
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
|
||
.logs-modal.show {
|
||
display: flex;
|
||
}
|
||
|
||
.logs-modal-content {
|
||
background: var(--card-base);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
width: min(90vw, 800px);
|
||
height: min(80vh, 600px);
|
||
box-shadow: 0 25px 80px rgba(0, 0, 0, .5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.logs-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: color-mix(in srgb, var(--card-base) 95%, var(--accent) 5%);
|
||
}
|
||
|
||
.logs-header h3 {
|
||
margin: 0;
|
||
color: var(--fg);
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.logs-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.logs-controls label {
|
||
color: var(--muted);
|
||
font-size: .9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.logs-controls select {
|
||
padding: 4px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font-size: .85rem;
|
||
}
|
||
|
||
.pause-btn, .close-btn {
|
||
padding: 4px 8px !important;
|
||
font-size: .85rem !important;
|
||
min-width: auto !important;
|
||
}
|
||
|
||
.pause-btn.paused {
|
||
background: color-mix(in srgb, #ff6600 15%, transparent) !important;
|
||
border-color: #ff6600 !important;
|
||
color: #ff6600 !important;
|
||
}
|
||
|
||
.close-btn {
|
||
background: color-mix(in srgb, var(--bad-fg) 15%, transparent) !important;
|
||
border-color: var(--bad-fg) !important;
|
||
color: var(--bad-fg) !important;
|
||
}
|
||
|
||
.logs-container {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.logs-content {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||
font-size: .8rem;
|
||
line-height: 1.4;
|
||
background: color-mix(in srgb, var(--bg) 60%, var(--card-base) 40%);
|
||
}
|
||
|
||
.logs-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: var(--muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.log-entry {
|
||
margin-bottom: 4px;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
border-left: 3px solid transparent;
|
||
transition: all .2s ease;
|
||
word-wrap: break-word;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.log-entry:hover {
|
||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||
}
|
||
|
||
.log-entry.error {
|
||
border-left-color: var(--bad-fg);
|
||
background: color-mix(in srgb, var(--bad-fg) 5%, transparent);
|
||
color: color-mix(in srgb, var(--bad-fg) 90%, var(--fg) 10%);
|
||
}
|
||
|
||
.log-entry.warning {
|
||
border-left-color: #ffaa00;
|
||
background: color-mix(in srgb, #ffaa00 5%, transparent);
|
||
color: color-mix(in srgb, #ffaa00 90%, var(--fg) 10%);
|
||
}
|
||
|
||
.log-entry.info {
|
||
border-left-color: var(--accent);
|
||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||
color: var(--fg);
|
||
}
|
||
|
||
.log-timestamp {
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.log-level {
|
||
font-weight: 700;
|
||
margin-right: 8px;
|
||
text-transform: uppercase;
|
||
font-size: .75rem;
|
||
}
|
||
|
||
.log-message {
|
||
color: var(--fg);
|
||
}
|
||
|
||
/* Response time display */
|
||
.response-row {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
margin-top: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.response-time {
|
||
font-size: .75rem;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
letter-spacing: .3px;
|
||
text-transform: uppercase;
|
||
transition: all .2s ease;
|
||
min-width: 60px;
|
||
text-align: center;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
/* Response time color coding */
|
||
.response-time.excellent {
|
||
background: color-mix(in srgb, #00ff88 15%, var(--card-base) 85%);
|
||
color: #00ff88;
|
||
border-color: color-mix(in srgb, #00ff88 30%, transparent 70%);
|
||
box-shadow: 0 0 8px color-mix(in srgb, #00ff88 20%, transparent 80%);
|
||
}
|
||
|
||
.response-time.good {
|
||
background: color-mix(in srgb, var(--ok-fg) 15%, var(--card-base) 85%);
|
||
color: var(--ok-fg);
|
||
border-color: color-mix(in srgb, var(--ok-fg) 30%, transparent 70%);
|
||
box-shadow: 0 0 6px color-mix(in srgb, var(--ok-fg) 15%, transparent 85%);
|
||
}
|
||
|
||
.response-time.fair {
|
||
background: color-mix(in srgb, #ffaa00 15%, var(--card-base) 85%);
|
||
color: #ffaa00;
|
||
border-color: color-mix(in srgb, #ffaa00 30%, transparent 70%);
|
||
box-shadow: 0 0 6px color-mix(in srgb, #ffaa00 15%, transparent 85%);
|
||
}
|
||
|
||
.response-time.slow {
|
||
background: color-mix(in srgb, #ff6600 15%, var(--card-base) 85%);
|
||
color: #ff6600;
|
||
border-color: color-mix(in srgb, #ff6600 30%, transparent 70%);
|
||
box-shadow: 0 0 6px color-mix(in srgb, #ff6600 15%, transparent 85%);
|
||
}
|
||
|
||
.response-time.timeout {
|
||
background: color-mix(in srgb, var(--bad-fg) 15%, var(--card-base) 85%);
|
||
color: var(--bad-fg);
|
||
border-color: color-mix(in srgb, var(--bad-fg) 30%, transparent 70%);
|
||
box-shadow: 0 0 6px color-mix(in srgb, var(--bad-fg) 15%, transparent 85%);
|
||
}
|
||
|
||
/* Light theme adjustments */
|
||
:root.light .response-time.excellent {
|
||
background: color-mix(in srgb, #00cc66 12%, white 88%);
|
||
color: #00aa55;
|
||
border-color: color-mix(in srgb, #00cc66 25%, transparent 75%);
|
||
box-shadow: 0 1px 3px color-mix(in srgb, #00cc66 20%, transparent 80%);
|
||
}
|
||
|
||
:root.light .response-time.fair {
|
||
background: color-mix(in srgb, #ff8800 12%, white 88%);
|
||
color: #cc6600;
|
||
border-color: color-mix(in srgb, #ff8800 25%, transparent 75%);
|
||
box-shadow: 0 1px 3px color-mix(in srgb, #ff8800 20%, transparent 80%);
|
||
}
|
||
|
||
:root.light .response-time.slow {
|
||
background: color-mix(in srgb, #ff4400 12%, white 88%);
|
||
color: #cc3300;
|
||
border-color: color-mix(in srgb, #ff4400 25%, transparent 75%);
|
||
box-shadow: 0 1px 3px color-mix(in srgb, #ff4400 20%, transparent 80%);
|
||
}
|
||
|
||
/* Blue theme adjustments */
|
||
:root.blue .response-time.excellent {
|
||
background: color-mix(in srgb, #88ffcc 18%, var(--card-base) 82%);
|
||
color: #88ffcc;
|
||
border-color: color-mix(in srgb, #88ffcc 35%, transparent 65%);
|
||
}
|
||
|
||
:root.blue .response-time.fair {
|
||
background: color-mix(in srgb, #ffcc88 18%, var(--card-base) 82%);
|
||
color: #ffcc88;
|
||
border-color: color-mix(in srgb, #ffcc88 35%, transparent 65%);
|
||
}
|
||
|
||
:root.blue .response-time.slow {
|
||
background: color-mix(in srgb, #ff8888 18%, var(--card-base) 82%);
|
||
color: #ff8888;
|
||
border-color: color-mix(in srgb, #ff8888 35%, transparent 65%);
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.top-row {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.tools-row {
|
||
justify-content: center;
|
||
}
|
||
|
||
.weather-widget-container {
|
||
max-width: 90vw;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: clamp(48px, 6vw, 64px);
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: clamp(1.5rem, 2.5vw, 2.5rem);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
body {
|
||
padding: 20px
|
||
}
|
||
|
||
#brand img {
|
||
max-width: 76vw
|
||
}
|
||
|
||
.card {
|
||
min-height: 170px;
|
||
padding: 12px 14px 56px
|
||
}
|
||
|
||
.weather-widget {
|
||
min-width: 180px;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: 36px;
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: 1.2rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
body {
|
||
padding: 16px
|
||
}
|
||
|
||
.grid .card {
|
||
min-width: 260px;
|
||
width: clamp(260px, 80vw, 340px)
|
||
}
|
||
|
||
.logo-wrap {
|
||
width: 56px;
|
||
height: 56px
|
||
}
|
||
|
||
.logo-img {
|
||
width: 48px;
|
||
height: 48px
|
||
}
|
||
|
||
:root {
|
||
--brand-min: 110px;
|
||
--brand-max: 240px;
|
||
--brand-h: clamp(var(--brand-min), 28vw, var(--brand-max));
|
||
}
|
||
|
||
.weather-widget {
|
||
min-width: 160px;
|
||
padding: 10px 14px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: 32px;
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.weather-location {
|
||
font-size: .8rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.grid .card {
|
||
min-width: 240px;
|
||
width: 100%
|
||
}
|
||
|
||
:root {
|
||
--brand-min: 100px;
|
||
--brand-max: 220px;
|
||
--brand-h: clamp(var(--brand-min), 32vw, var(--brand-max));
|
||
}
|
||
|
||
.weather-widget {
|
||
min-width: 140px;
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.weather-location {
|
||
font-size: .75rem;
|
||
}
|
||
}
|
||
|
||
/* qBittorrent title slight tweak for awkward wraps at some widths */
|
||
.card[data-app="torrent"] .row .name {
|
||
font-size: clamp(17px, 1vw + 16px, 22px);
|
||
}
|
||
|
||
/* Responsive behavior for weather widget */
|
||
@media (max-width: 1200px) {
|
||
#brand {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.weather-widget-container {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: clamp(48px, 6vw, 64px);
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: clamp(1.5rem, 2.5vw, 2.5rem);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
body {
|
||
padding: 20px;
|
||
}
|
||
|
||
#brand img {
|
||
max-width: 76vw;
|
||
}
|
||
|
||
.card {
|
||
min-height: 170px;
|
||
padding: 12px 14px 56px;
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: clamp(40px, 5vw, 56px);
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: clamp(1.3rem, 2.2vw, 2rem);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
body {
|
||
padding: 16px;
|
||
}
|
||
|
||
.grid .card {
|
||
min-width: 260px;
|
||
width: clamp(260px, 80vw, 340px);
|
||
}
|
||
|
||
.logo-wrap {
|
||
width: 56px;
|
||
height: 56px;
|
||
}
|
||
|
||
.logo-img {
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
:root {
|
||
--brand-min: 110px;
|
||
--brand-max: 240px;
|
||
--brand-h: clamp(var(--brand-min), 28vw, var(--brand-max));
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: clamp(36px, 4.5vw, 48px);
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: clamp(1.1rem, 2vw, 1.8rem);
|
||
}
|
||
|
||
.weather-location {
|
||
font-size: clamp(0.8rem, 1.1vw, 1rem);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.grid .card {
|
||
min-width: 240px;
|
||
width: 100%;
|
||
}
|
||
|
||
:root {
|
||
--brand-min: 100px;
|
||
--brand-max: 220px;
|
||
--brand-h: clamp(var(--brand-min), 32vw, var(--brand-max));
|
||
}
|
||
|
||
.weather-icon {
|
||
font-size: clamp(32px, 4vw, 40px);
|
||
}
|
||
|
||
.weather-temp {
|
||
font-size: clamp(1rem, 1.8vw, 1.5rem);
|
||
}
|
||
|
||
.weather-location {
|
||
font-size: clamp(0.75rem, 1vw, 0.9rem);
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- Top bar with logo and weather on same line, tools below -->
|
||
<div class="bar">
|
||
<div class="top-row">
|
||
<div id="brand">
|
||
<img class="brand-logo-top" src="/assets/SAMI-CLOUD.png" alt="SAMI-CLOUD logo" loading="eager" decoding="sync" />
|
||
</div>
|
||
|
||
<!-- Weather widget directly to the right of logo -->
|
||
<div class="weather-widget-container">
|
||
<div id="weather-widget" class="weather-widget">
|
||
<div class="weather-content">
|
||
<div class="weather-icon">🌤️</div>
|
||
<div class="weather-info">
|
||
<div class="weather-location">--</div>
|
||
<div class="weather-temp">--°</div>
|
||
<div class="weather-condition">--</div>
|
||
<div class="weather-wind">--</div>
|
||
<button id="weather-settings" class="weather-settings-btn-bottom" aria-label="Weather settings">⚙️</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tools row below logo and weather -->
|
||
<div class="tools-row">
|
||
<div class="tools">
|
||
<div class="chip muted" id="stamp">last check: —</div>
|
||
<button id="theme" aria-label="Toggle theme">Theme</button>
|
||
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
|
||
<button id="add-service" aria-label="Add new service">+ Add Service</button>
|
||
<button id="export-dashboard" aria-label="Export dashboard configuration">📤 Export</button>
|
||
<button id="import-dashboard" aria-label="Import dashboard configuration">📥 Import</button>
|
||
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
|
||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
|
||
<button id="container-stats-btn" aria-label="Container resource stats">📊 Stats</button>
|
||
<button id="refresh"><span class="brand-spinner" aria-hidden="true"></span>Refresh</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top Anchor Row -->
|
||
<div class="top">
|
||
<div class="card" data-app="dns1" data-status="off">
|
||
<span id="dns1-dot" class="dot bad at-bl"></span>
|
||
<div class="row">
|
||
<div class="logo-wrap">
|
||
<svg viewBox="0 0 24 24" class="service-icon">
|
||
<rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/>
|
||
<rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/>
|
||
<rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/>
|
||
<circle cx="17" cy="11" r="2" fill="#e74c3c"/>
|
||
<path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/>
|
||
</svg>
|
||
</div>
|
||
<span class="name">DNS1</span>
|
||
<span class="spacer"></span>
|
||
<span id="dns1-pill" class="badge off">OFF</span>
|
||
</div>
|
||
<div class="response-row">
|
||
<span id="dns1-time" class="response-time">--</span>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button id="dns1-restart" class="restart-btn">Restart</button>
|
||
<button id="dns1-open">Open</button>
|
||
<button id="dns1-logs" class="logs-btn">Logs</button>
|
||
<button id="dns1-settings" class="settings-btn">⚙️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" data-app="dns2" data-status="off">
|
||
<span id="dns2-dot" class="dot bad at-bl"></span>
|
||
<div class="row">
|
||
<div class="logo-wrap">
|
||
<svg viewBox="0 0 24 24" class="service-icon">
|
||
<rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/>
|
||
<rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/>
|
||
<rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/>
|
||
<rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/>
|
||
<circle cx="17" cy="11" r="2" fill="#e74c3c"/>
|
||
<path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/>
|
||
</svg>
|
||
</div>
|
||
<span class="name">DNS2</span>
|
||
<span class="spacer"></span>
|
||
<span id="dns2-pill" class="badge off">OFF</span>
|
||
</div>
|
||
<div class="response-row">
|
||
<span id="dns2-time" class="response-time">--</span>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button id="dns2-restart" class="restart-btn">Restart</button>
|
||
<button id="dns2-open">Open</button>
|
||
<button id="dns2-logs" class="logs-btn">Logs</button>
|
||
<button id="dns2-settings" class="settings-btn">⚙️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" data-app="internet" data-status="off">
|
||
<span id="internet-dot" class="dot bad at-bl"></span>
|
||
<div class="row">
|
||
<div class="logo-wrap">
|
||
<svg viewBox="0 0 24 24" class="service-icon">
|
||
<circle cx="12" cy="12" r="10" fill="#27ae60"/>
|
||
<path d="M12 2c-2.5 4-2.5 14 0 18M12 2c2.5 4 2.5 14 0 18M2 12h20" stroke="white" stroke-width="1.5"/>
|
||
<path d="M4.5 7.5c3-1.5 6-1.5 9 0M4.5 16.5c3 1.5 6 1.5 9 0" stroke="white" stroke-width="1"/>
|
||
<path d="M10.5 7.5c3-1.5 6-1.5 9 0M10.5 16.5c3 1.5 6 1.5 9 0" stroke="white" stroke-width="1"/>
|
||
</svg>
|
||
</div>
|
||
<span class="name">Internet</span>
|
||
<span class="spacer"></span>
|
||
<span id="internet-pill" class="badge off">OFF</span>
|
||
</div>
|
||
<div class="response-row">
|
||
<span id="internet-time" class="response-time">--</span>
|
||
</div>
|
||
<div class="btn-row"><!-- No button for Internet --></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reload Caddy Button -->
|
||
<div style="text-align: center; margin: 24px 0 16px 0;">
|
||
<button id="reload-caddy" aria-label="Reload Caddy configuration" style="padding: 14px 40px; font-size: 1.15rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 8px; color: white; cursor: pointer; box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;">
|
||
🔄 Reload Caddy
|
||
</button>
|
||
</div>
|
||
|
||
<!-- App/service grid -->
|
||
<div id="cards" class="grid"></div>
|
||
|
||
<!-- App Selector Button -->
|
||
<div style="text-align: center; margin: 32px 0; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;">
|
||
<button id="add-service-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📱 App Selector</button>
|
||
<button id="arr-setup-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none;">🎬 Connect Arr Stack</button>
|
||
</div>
|
||
|
||
<!-- App Selector Modal -->
|
||
<div id="app-selector-modal" class="weather-modal">
|
||
<div class="app-selector-content">
|
||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<button id="app-selector-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- App Deployment Configuration Modal -->
|
||
<div id="app-deploy-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
||
<h3 id="app-deploy-title">Deploy Application</h3>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 16px;">
|
||
<!-- Subdomain/Domain -->
|
||
<div>
|
||
<label for="deploy-subdomain" style="display: block; margin-bottom: 6px; color: var(--accent); font-weight: 500;">
|
||
🌐 Subdomain or Domain
|
||
</label>
|
||
<input type="text" id="deploy-subdomain" placeholder="uptime"
|
||
style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.95rem;" />
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">
|
||
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.sami</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Configuration -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🗂️ DNS Configuration
|
||
</label>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="dns-type" value="private" checked style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Private DNS (Technitium)</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Use your local DNS server with custom TLD (.sami, .home, etc.)</div>
|
||
</div>
|
||
</label>
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="dns-type" value="public" style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Public DNS</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Use a real domain (example.com) - requires DNS provider setup</div>
|
||
</div>
|
||
</label>
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="dns-type" value="none" style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">No DNS (IP:Port only)</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Access via IP address and port - no domain setup</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSL Configuration -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🔒 SSL/TLS Certificate
|
||
</label>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="ssl-type" value="internal" checked style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Internal CA</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Use Caddy's internal certificate authority (self-signed)</div>
|
||
</div>
|
||
</label>
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="ssl-type" value="letsencrypt" style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Let's Encrypt</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Free public SSL certificate (requires public domain)</div>
|
||
</div>
|
||
</label>
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="radio" name="ssl-type" value="none" style="margin-right: 10px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">No SSL (HTTP only)</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">⚠️ Not recommended - traffic will be unencrypted</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tailscale Security -->
|
||
<div id="tailscale-section">
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🔐 Tailscale Security
|
||
</label>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<label style="display: flex; align-items: center; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="checkbox" id="deploy-tailscale-only" style="margin-right: 10px; width: 18px; height: 18px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Tailscale-Only Access</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Restrict this service to Tailscale users only (100.x.x.x IPs)</div>
|
||
</div>
|
||
</label>
|
||
<div id="tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||
<span style="color: var(--muted);">Checking Tailscale status...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced Options (Collapsible) -->
|
||
<details>
|
||
<summary style="cursor: pointer; color: var(--accent); font-weight: 500; margin-bottom: 8px;">⚙️ Advanced Options</summary>
|
||
<div style="margin-top: 12px; display: grid; gap: 12px;">
|
||
<div>
|
||
<label for="deploy-port" style="display: block; margin-bottom: 6px;">Custom Port (optional)</label>
|
||
<input type="number" id="deploy-port" placeholder="Leave empty for default"
|
||
style="width: 100%; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
</div>
|
||
<div>
|
||
<label for="deploy-ip" style="display: block; margin-bottom: 6px;">Target IP Address</label>
|
||
<input type="text" id="deploy-ip" value="localhost"
|
||
style="width: 100%; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">
|
||
Use 'localhost' for same-host containers, or specific IP for remote services
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||
<button id="app-deploy-cancel">Cancel</button>
|
||
<button id="app-deploy-confirm" style="background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
|
||
🚀 Deploy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Initial Setup Wizard -->
|
||
<div id="setup-wizard" class="setup-wizard" style="display: none;">
|
||
<div class="setup-wizard-content">
|
||
<!-- Step 1: Welcome -->
|
||
<div class="setup-step" id="setup-step-1" style="display: block;">
|
||
<h2 style="margin: 0 0 16px; text-align: center;">Welcome to DashCaddy! 🎉</h2>
|
||
<p style="text-align: center; color: var(--muted); margin-bottom: 32px;">
|
||
Let's set up your self-hosted app dashboard. This will only take a few minutes.
|
||
</p>
|
||
|
||
<h3 style="margin: 24px 0 16px; color: var(--accent);">Choose your configuration:</h3>
|
||
|
||
<div class="setup-options">
|
||
<label class="setup-option" data-preset="simple">
|
||
<input type="radio" name="config-type" value="simple" />
|
||
<div class="setup-option-content">
|
||
<div class="setup-option-icon">🚀</div>
|
||
<div class="setup-option-title">Simple</div>
|
||
<div class="setup-option-desc">Access apps via IP:Port only. No DNS or SSL setup needed.</div>
|
||
<div class="setup-option-example">Example: http://192.168.1.100:8080</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="setup-option" data-preset="homelab">
|
||
<input type="radio" name="config-type" value="homelab" checked />
|
||
<div class="setup-option-content">
|
||
<div class="setup-option-icon">🏠</div>
|
||
<div class="setup-option-title">Professional Home Lab</div>
|
||
<div class="setup-option-desc">Custom TLD + Private DNS + Internal CA. Full HTTPS with your own certificate authority.</div>
|
||
<div class="setup-option-example">Example: https://uptime.sami</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="setup-option" data-preset="public">
|
||
<input type="radio" name="config-type" value="public" />
|
||
<div class="setup-option-content">
|
||
<div class="setup-option-icon">🌍</div>
|
||
<div class="setup-option-title">Public Server</div>
|
||
<div class="setup-option-desc">Real domain + Public DNS + Let's Encrypt. Internet-accessible with trusted SSL.</div>
|
||
<div class="setup-option-example">Example: https://cloud.example.com</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="setup-option" data-preset="custom">
|
||
<input type="radio" name="config-type" value="custom" />
|
||
<div class="setup-option-content">
|
||
<div class="setup-option-icon">⚙️</div>
|
||
<div class="setup-option-title">Custom</div>
|
||
<div class="setup-option-desc">I'll configure everything myself. Show me all options.</div>
|
||
<div class="setup-option-example">Full control over every setting</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setup-wizard-buttons">
|
||
<button id="setup-skip" style="margin-right: auto; background: transparent; border: 1px solid var(--muted); color: var(--muted);">Skip Setup</button>
|
||
<button id="setup-step-1-next" class="setup-btn-primary">Continue →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: Home Lab Configuration -->
|
||
<div class="setup-step" id="setup-step-homelab" style="display: none;">
|
||
<h2 style="margin: 0 0 8px;">Professional Home Lab Setup</h2>
|
||
<p style="color: var(--muted); margin-bottom: 24px;">Configure your custom TLD and certificate authority</p>
|
||
|
||
<div style="display: grid; gap: 24px;">
|
||
<!-- Custom TLD -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🌐 Custom Top-Level Domain (TLD)
|
||
</label>
|
||
<input type="text" id="setup-tld" value=".sami" placeholder=".home"
|
||
style="width: 100%; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
Your apps will use this TLD. Examples: .sami, .home, .lab, .local, .internal
|
||
</div>
|
||
<div style="font-size: 0.85rem; color: var(--accent); margin-top: 8px; font-weight: 500;">
|
||
📱 Preview: uptime<span id="tld-preview">.sami</span>, nextcloud<span id="tld-preview-2">.sami</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Certificate Authority Name -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🔒 Certificate Authority Name
|
||
</label>
|
||
<input type="text" id="setup-ca-name" value="Sami Home Network Root CA" placeholder="My Home Lab Root CA"
|
||
style="width: 100%; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
This name appears in browser certificate details. Make it memorable!
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Server Configuration -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🗂️ DNS Server (Technitium)
|
||
</label>
|
||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
|
||
<input type="text" id="setup-dns-ip" value="192.168.254.204" placeholder="192.168.1.1"
|
||
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<input type="number" id="setup-dns-port" value="5380" placeholder="5380"
|
||
style="width: 100px; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
</div>
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
DashCaddy will automatically create DNS records for your apps
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Admin Token -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🔑 Technitium Admin Token
|
||
</label>
|
||
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
|
||
style="width: 100%; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<details style="margin-top: 8px;">
|
||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.85rem;">ℹ️ How to get your token</summary>
|
||
<div style="margin-top: 8px; padding: 12px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px; font-size: 0.85rem;">
|
||
<ol style="margin: 0; padding-left: 20px; color: var(--muted);">
|
||
<li>Open Technitium DNS web panel</li>
|
||
<li>Go to <strong>Settings → API</strong></li>
|
||
<li>Create a new token with <strong>"Administration"</strong> permission</li>
|
||
<li>Copy the token and paste it here</li>
|
||
</ol>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setup-wizard-buttons">
|
||
<button id="setup-homelab-back">← Back</button>
|
||
<button id="setup-homelab-next" class="setup-btn-primary">Continue →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Simple Configuration -->
|
||
<div class="setup-step" id="setup-step-simple" style="display: none;">
|
||
<h2 style="margin: 0 0 8px;">Simple Setup</h2>
|
||
<p style="color: var(--muted); margin-bottom: 24px;">Access your apps directly via IP addresses and ports</p>
|
||
|
||
<div style="padding: 24px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 12px; border: 1px solid var(--border);">
|
||
<h3 style="margin: 0 0 16px; color: var(--accent);">✓ What you'll have:</h3>
|
||
<ul style="margin: 0; padding-left: 20px; line-height: 1.8;">
|
||
<li>Direct access via IP:Port (e.g., http://192.168.1.100:8080)</li>
|
||
<li>No DNS configuration needed</li>
|
||
<li>No SSL/certificate setup</li>
|
||
<li>Apps work immediately after deployment</li>
|
||
</ul>
|
||
|
||
<h3 style="margin: 24px 0 16px; color: var(--accent);">⚠️ Limitations:</h3>
|
||
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: var(--muted);">
|
||
<li>Must remember IP:Port for each app</li>
|
||
<li>No pretty URLs</li>
|
||
<li>HTTP only (no HTTPS encryption)</li>
|
||
<li>Not recommended for sensitive data</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div style="margin-top: 24px; padding: 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">
|
||
Default Server IP Address
|
||
</label>
|
||
<input type="text" id="setup-simple-ip" value="localhost" placeholder="192.168.1.100"
|
||
style="width: 100%; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
Use "localhost" for same-host deployments, or your server's IP
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setup-wizard-buttons">
|
||
<button id="setup-simple-back">← Back</button>
|
||
<button id="setup-simple-next" class="setup-btn-primary">Continue →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 4: Public Server Configuration -->
|
||
<div class="setup-step" id="setup-step-public" style="display: none;">
|
||
<h2 style="margin: 0 0 8px;">Public Server Setup</h2>
|
||
<p style="color: var(--muted); margin-bottom: 24px;">Configure for internet-accessible apps with Let's Encrypt SSL</p>
|
||
|
||
<div style="display: grid; gap: 20px;">
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
🌍 Your Domain
|
||
</label>
|
||
<input type="text" id="setup-public-domain" placeholder="example.com"
|
||
style="width: 100%; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
Your registered domain name (must point to this server)
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: var(--accent); font-weight: 500;">
|
||
📧 Email for Let's Encrypt
|
||
</label>
|
||
<input type="email" id="setup-public-email" placeholder="admin@example.com"
|
||
style="width: 100%; padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 6px;">
|
||
Required for Let's Encrypt certificate renewal notifications
|
||
</div>
|
||
</div>
|
||
|
||
<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
|
||
<h4 style="margin: 0 0 12px; color: var(--accent);">⚠️ Requirements:</h4>
|
||
<ul style="margin: 0; padding-left: 20px; font-size: 0.9rem; line-height: 1.6;">
|
||
<li>Port 80 and 443 must be open to the internet</li>
|
||
<li>Domain DNS must point to this server's public IP</li>
|
||
<li>Server must be reachable from the internet</li>
|
||
<li>You'll need to configure DNS manually for each subdomain</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setup-wizard-buttons">
|
||
<button id="setup-public-back">← Back</button>
|
||
<button id="setup-public-next" class="setup-btn-primary">Continue →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 5: Summary & Confirmation -->
|
||
<div class="setup-step" id="setup-step-summary" style="display: none;">
|
||
<h2 style="margin: 0 0 8px;">Review Your Configuration</h2>
|
||
<p style="color: var(--muted); margin-bottom: 24px;">Make sure everything looks correct before finishing</p>
|
||
|
||
<div id="setup-summary-content" style="padding: 24px; background: var(--card-bg); border-radius: 12px; border: 1px solid var(--border);">
|
||
<!-- Will be filled dynamically -->
|
||
</div>
|
||
|
||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||
Go to Settings → System Configuration to edit your setup anytime
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setup-wizard-buttons">
|
||
<button id="setup-summary-back">← Back</button>
|
||
<button id="setup-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Weather Settings Modal -->
|
||
<div id="weather-modal" class="weather-modal">
|
||
<div class="weather-modal-content">
|
||
<h3>Weather Settings</h3>
|
||
<label for="zip-input">ZIP Code:</label>
|
||
<input type="text" id="zip-input" placeholder="Enter ZIP code (e.g., 90210)" maxlength="10">
|
||
<div class="weather-modal-buttons">
|
||
<button id="weather-cancel">Cancel</button>
|
||
<button id="weather-save">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Logs Modal -->
|
||
<div id="logs-modal" class="logs-modal">
|
||
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||
<div class="logs-header">
|
||
<h3 id="logs-title">DNS Logs</h3>
|
||
<div class="logs-controls">
|
||
<label for="log-lines">Show:</label>
|
||
<select id="log-lines">
|
||
<option value="25" selected>25</option>
|
||
<option value="50">50</option>
|
||
<option value="100">100</option>
|
||
<option value="200">200</option>
|
||
</select>
|
||
<button id="logs-pause" class="pause-btn">⏸️ Pause</button>
|
||
<button id="logs-close" class="close-btn">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="logs-container" style="max-height: 500px; overflow-y: auto;">
|
||
<div id="logs-content" class="logs-content">
|
||
<div class="logs-loading">Loading logs...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token Management Modal -->
|
||
<div id="token-management-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||
<h3>🔑 API Token Management</h3>
|
||
|
||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||
Configure API tokens for DNS services. Use read-only tokens for logs and admin tokens for restart operations.
|
||
</p>
|
||
|
||
<!-- DNS1 Credentials -->
|
||
<div class="token-section">
|
||
<h4 class="token-section-title">DNS1 (Windows)</h4>
|
||
<div class="token-grid">
|
||
<div class="token-field">
|
||
<label for="dns1-readonly-username">📖 Read-Only (Logs):</label>
|
||
<input type="text" id="dns1-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||
<div class="token-input-row">
|
||
<input type="password" id="dns1-readonly-token" placeholder="Token" autocomplete="off" />
|
||
<button type="button" class="token-toggle" data-target="dns1-readonly-token">👁</button>
|
||
</div>
|
||
</div>
|
||
<div class="token-field">
|
||
<label for="dns1-admin-username">🔧 Admin (Restart):</label>
|
||
<input type="text" id="dns1-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||
<div class="token-input-row">
|
||
<input type="password" id="dns1-admin-token" placeholder="Token" autocomplete="off" />
|
||
<button type="button" class="token-toggle" data-target="dns1-admin-token">👁</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="token-status" id="dns1-token-status"></div>
|
||
</div>
|
||
|
||
<!-- DNS2 Credentials -->
|
||
<div class="token-section">
|
||
<h4 class="token-section-title">DNS2 (Linux)</h4>
|
||
<div class="token-grid">
|
||
<div class="token-field">
|
||
<label for="dns2-readonly-username">📖 Read-Only (Logs):</label>
|
||
<input type="text" id="dns2-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||
<div class="token-input-row">
|
||
<input type="password" id="dns2-readonly-token" placeholder="Token" autocomplete="off" />
|
||
<button type="button" class="token-toggle" data-target="dns2-readonly-token">👁</button>
|
||
</div>
|
||
</div>
|
||
<div class="token-field">
|
||
<label for="dns2-admin-username">🔧 Admin (Restart):</label>
|
||
<input type="text" id="dns2-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||
<div class="token-input-row">
|
||
<input type="password" id="dns2-admin-token" placeholder="Token" autocomplete="off" />
|
||
<button type="button" class="token-toggle" data-target="dns2-admin-token">👁</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="token-status" id="dns2-token-status"></div>
|
||
</div>
|
||
|
||
<details style="margin-top: 12px;">
|
||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.85rem;">ℹ️ How to get API tokens</summary>
|
||
<div style="margin-top: 8px; font-size: 0.8rem; color: var(--muted); padding: 8px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px;">
|
||
<ol style="margin: 0; padding-left: 20px;">
|
||
<li>Open Technitium DNS Admin Panel</li>
|
||
<li>Go to <strong>Settings → API</strong></li>
|
||
<li>Create tokens with appropriate permissions:
|
||
<ul style="margin: 4px 0;">
|
||
<li><strong>Read-only:</strong> Enable only "Logs" permission</li>
|
||
<li><strong>Admin:</strong> Enable "Administration" permission</li>
|
||
</ul>
|
||
</li>
|
||
<li>Copy the generated tokens here</li>
|
||
</ol>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
|
||
<button id="token-cancel">Cancel</button>
|
||
<button id="token-save" style="background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">Save Tokens</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Service Edit Modal -->
|
||
<div id="service-edit-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||
<h3 id="service-edit-title">Edit Service</h3>
|
||
|
||
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
||
<!-- Service Info -->
|
||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--card-bg); border-radius: 8px;">
|
||
<img id="edit-service-logo-preview" src="" alt="" style="width: 48px; height: 48px; border-radius: 8px; object-fit: contain; background: var(--bg);" />
|
||
<div>
|
||
<div id="edit-service-name-display" style="font-weight: 600; font-size: 1.1rem;"></div>
|
||
<div id="edit-service-url-display" style="font-size: 0.85rem; color: var(--muted);"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subdomain -->
|
||
<div>
|
||
<label for="edit-subdomain" style="display: block; margin-bottom: 6px; color: var(--accent); font-weight: 500;">
|
||
Subdomain
|
||
</label>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<input type="text" id="edit-subdomain" style="flex: 1; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
<span style="color: var(--muted);">.sami</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Port -->
|
||
<div>
|
||
<label for="edit-port" style="display: block; margin-bottom: 6px; color: var(--accent); font-weight: 500;">
|
||
Port
|
||
</label>
|
||
<input type="number" id="edit-port" style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">
|
||
The port Caddy will proxy to (container's exposed port)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- IP Address -->
|
||
<div>
|
||
<label for="edit-ip" style="display: block; margin-bottom: 6px; color: var(--accent); font-weight: 500;">
|
||
IP Address
|
||
</label>
|
||
<input type="text" id="edit-ip" style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
</div>
|
||
|
||
<!-- Tailscale Protection -->
|
||
<div>
|
||
<label style="display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||
<input type="checkbox" id="edit-tailscale-only" style="width: 18px; height: 18px;" />
|
||
<div>
|
||
<div style="font-weight: 500;">Tailscale-Only Access</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted);">Restrict this service to Tailscale users only</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Logo -->
|
||
<div>
|
||
<label style="display: block; margin-bottom: 6px; color: var(--accent); font-weight: 500;">
|
||
Service Logo
|
||
</label>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<input type="text" id="edit-logo-url" placeholder="/assets/service.png or https://..." style="flex: 1; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||
<label style="padding: 10px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; white-space: nowrap;">
|
||
<input type="file" id="edit-logo-file" accept="image/*" style="display: none;" />
|
||
Upload
|
||
</label>
|
||
</div>
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">
|
||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||
<button id="service-edit-cancel">Cancel</button>
|
||
<button id="service-edit-save" style="background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error Log Viewer Modal -->
|
||
<div id="error-log-modal" class="logs-modal">
|
||
<div class="logs-modal-content">
|
||
<div class="logs-header">
|
||
<h3>📋 Error Logs</h3>
|
||
<div class="logs-controls">
|
||
<button id="error-log-refresh" style="padding: 4px 12px !important; font-size: .85rem !important;">🔄 Refresh</button>
|
||
<button id="error-log-clear" style="padding: 4px 12px !important; font-size: .85rem !important; background: color-mix(in srgb, var(--bad-fg) 15%, transparent) !important; border-color: var(--bad-fg) !important; color: var(--bad-fg) !important;">🗑️ Clear</button>
|
||
<button id="error-log-close" class="close-btn">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="logs-container">
|
||
<div id="error-log-content" class="logs-content">
|
||
<div class="logs-loading">Loading error logs...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notifications Settings Modal -->
|
||
<div id="notifications-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||
<h3>🔔 Notification Settings</h3>
|
||
|
||
<!-- Master Toggle -->
|
||
<div style="margin-bottom: 16px; padding: 12px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
|
||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
||
<input type="checkbox" id="notifications-enabled" />
|
||
<div>
|
||
<span style="font-weight: 600; color: var(--accent);">Enable Notifications</span>
|
||
<div style="font-size: 0.75rem; color: var(--muted);">Receive alerts when containers go up/down</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Providers Section -->
|
||
<h4 style="margin: 16px 0 8px; color: var(--accent); font-size: 0.9rem;">Notification Providers</h4>
|
||
|
||
<!-- Discord -->
|
||
<div class="notification-provider" style="padding: 12px; background: var(--card-base); border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||
<input type="checkbox" id="discord-enabled" />
|
||
<span style="font-weight: 500;">Discord</span>
|
||
</label>
|
||
<button id="discord-test" class="test-btn" style="padding: 4px 10px; font-size: 0.75rem;">Test</button>
|
||
</div>
|
||
<div id="discord-config" style="display: none;">
|
||
<label style="font-size: 0.8rem;">Webhook URL:</label>
|
||
<input type="text" id="discord-webhook" placeholder="https://discord.com/api/webhooks/..." style="width: 100%;" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Telegram -->
|
||
<div class="notification-provider" style="padding: 12px; background: var(--card-base); border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||
<input type="checkbox" id="telegram-enabled" />
|
||
<span style="font-weight: 500;">Telegram</span>
|
||
</label>
|
||
<button id="telegram-test" class="test-btn" style="padding: 4px 10px; font-size: 0.75rem;">Test</button>
|
||
</div>
|
||
<div id="telegram-config" style="display: none;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label style="font-size: 0.8rem;">Bot Token:</label>
|
||
<input type="text" id="telegram-bot-token" placeholder="123456:ABC..." />
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 0.8rem;">Chat ID:</label>
|
||
<input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ntfy.sh -->
|
||
<div class="notification-provider" style="padding: 12px; background: var(--card-base); border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||
<input type="checkbox" id="ntfy-enabled" />
|
||
<span style="font-weight: 500;">ntfy.sh</span>
|
||
</label>
|
||
<button id="ntfy-test" class="test-btn" style="padding: 4px 10px; font-size: 0.75rem;">Test</button>
|
||
</div>
|
||
<div id="ntfy-config" style="display: none;">
|
||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label style="font-size: 0.8rem;">Server URL:</label>
|
||
<input type="text" id="ntfy-server" placeholder="https://ntfy.sh" value="https://ntfy.sh" />
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 0.8rem;">Topic:</label>
|
||
<input type="text" id="ntfy-topic" placeholder="dashcaddy-alerts" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Health Check Settings -->
|
||
<h4 style="margin: 16px 0 8px; color: var(--accent); font-size: 0.9rem;">Health Monitoring</h4>
|
||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
||
<input type="checkbox" id="health-check-enabled" />
|
||
<div>
|
||
<span style="font-weight: 500;">Enable Health Monitoring</span>
|
||
<div style="font-size: 0.75rem; color: var(--muted);">Periodically check container status</div>
|
||
</div>
|
||
</label>
|
||
<div id="health-check-config" style="display: flex; align-items: center; gap: 10px;">
|
||
<label style="font-size: 0.8rem;">Check interval:</label>
|
||
<select id="health-check-interval" style="width: auto;">
|
||
<option value="1">1 minute</option>
|
||
<option value="5" selected>5 minutes</option>
|
||
<option value="15">15 minutes</option>
|
||
<option value="30">30 minutes</option>
|
||
<option value="60">1 hour</option>
|
||
</select>
|
||
<button id="health-check-now" style="padding: 4px 10px; font-size: 0.75rem; margin-left: auto;">Check Now</button>
|
||
</div>
|
||
<div id="health-check-status" style="font-size: 0.75rem; color: var(--muted); margin-top: 8px;">
|
||
Last check: Never
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Event Types -->
|
||
<h4 style="margin: 16px 0 8px; color: var(--accent); font-size: 0.9rem;">Events to Notify</h4>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="event-container-down" checked /> Container Down
|
||
</label>
|
||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="event-container-up" checked /> Container Recovered
|
||
</label>
|
||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="event-deploy-success" checked /> Deployment Success
|
||
</label>
|
||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
||
</label>
|
||
</div>
|
||
|
||
<!-- History -->
|
||
<h4 style="margin: 16px 0 8px; color: var(--accent); font-size: 0.9rem;">Notification History</h4>
|
||
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
|
||
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
|
||
</div>
|
||
|
||
<!-- Buttons -->
|
||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||
<button id="notifications-cancel">Cancel</button>
|
||
<button id="notifications-save" style="background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">Save Settings</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Backup/Restore Modal -->
|
||
<div id="backup-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||
<h3>💾 Backup & Restore</h3>
|
||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||
Export your DashCaddy configuration or restore from a previous backup.
|
||
</p>
|
||
|
||
<!-- Export Section -->
|
||
<div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
|
||
<h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">📤 Export Backup</h4>
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||
Download all your settings: services, Caddyfile, DNS credentials, notifications.
|
||
</p>
|
||
<button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
⬇️ Download Backup
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Import Section -->
|
||
<div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;">
|
||
<h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">📥 Restore Backup</h4>
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||
Upload a backup file to restore your configuration.
|
||
</p>
|
||
<input type="file" id="backup-file-input" accept=".json" style="display: none;" />
|
||
<button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
📁 Select Backup File
|
||
</button>
|
||
<div id="backup-file-name" style="display: none; margin-top: 8px; padding: 8px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"></div>
|
||
</div>
|
||
|
||
<!-- Preview Section (shown after file selected) -->
|
||
<div id="backup-preview" style="display: none; margin-top: 16px; padding: 16px; background: var(--card-base); border-radius: 10px; border: 1px solid var(--border);">
|
||
<h4 style="margin: 0 0 12px; font-size: 0.9rem;">📋 Backup Contents</h4>
|
||
<div id="backup-preview-content" style="font-size: 0.85rem;"></div>
|
||
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="backup-reload-caddy" checked />
|
||
Reload Caddy after restore
|
||
</label>
|
||
</div>
|
||
<button id="backup-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
⚡ Restore Configuration
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Result Message -->
|
||
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
|
||
<!-- Close Button -->
|
||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||
<button id="backup-cancel">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Container Stats Modal -->
|
||
<div id="stats-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px;">
|
||
<h3>📊 Container Resources</h3>
|
||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||
Real-time CPU, memory, and network usage for running containers.
|
||
</p>
|
||
|
||
<!-- Stats Container -->
|
||
<div id="stats-container" style="max-height: 500px; overflow-y: auto;">
|
||
<div style="text-align: center; padding: 40px; color: var(--muted);">
|
||
<span class="brand-spinner"></span> Loading container stats...
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auto-refresh toggle -->
|
||
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" id="stats-auto-refresh" checked />
|
||
Auto-refresh every 5s
|
||
</label>
|
||
<button id="stats-refresh-btn" style="padding: 6px 12px; font-size: 0.8rem;">🔄 Refresh Now</button>
|
||
<span id="stats-last-update" style="margin-left: auto; font-size: 0.75rem; color: var(--muted);"></span>
|
||
</div>
|
||
|
||
<!-- Close Button -->
|
||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||
<button id="stats-cancel">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Arr Stack Setup Modal -->
|
||
<div id="arr-setup-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
||
<h3>🎬 Arr Stack Integration</h3>
|
||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||
Connect Overseerr to your Radarr and Sonarr instances.
|
||
</p>
|
||
|
||
<!-- Overseerr Status -->
|
||
<div id="arr-overseerr-status" style="margin-bottom: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<span id="overseerr-status-icon" style="font-size: 1.2rem;">⏳</span>
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 500;">Overseerr</div>
|
||
<div id="overseerr-status-text" style="font-size: 0.75rem; color: var(--muted);">Checking...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LOCAL CONTAINERS SECTION -->
|
||
<div id="arr-local-section" style="display: none; margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
|
||
<h4 style="margin: 0 0 12px; color: #2ecc71; font-size: 0.95rem;">🏠 Local Containers Detected</h4>
|
||
<div id="arr-local-services" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;"></div>
|
||
<button id="arr-auto-connect" style="width: 100%; padding: 12px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; font-size: 0.95rem; border-radius: 8px; cursor: pointer;">
|
||
⚡ One-Click Auto-Connect
|
||
</button>
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 8px 0 0; text-align: center;">
|
||
Automatically extracts API keys, quality profiles, and root folders
|
||
</p>
|
||
</div>
|
||
|
||
<!-- EXTERNAL SERVICES SECTION -->
|
||
<div id="arr-external-section">
|
||
<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">🌐 External Services (Seedbox)</h4>
|
||
|
||
<!-- Radarr Config -->
|
||
<div style="margin-bottom: 12px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<span style="font-size: 1.1rem;">🎬</span>
|
||
<span style="font-weight: 500;">Radarr (Movies)</span>
|
||
<span id="radarr-test-status" style="margin-left: auto; font-size: 0.75rem;"></span>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||
<input type="text" id="arr-radarr-url" placeholder="https://seedbox.com/radarr/" style="width: 100%; font-size: 0.85rem;" />
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||
<input type="password" id="arr-radarr-key" placeholder="From Settings → General" style="width: 100%; font-size: 0.85rem;" />
|
||
</div>
|
||
</div>
|
||
<button id="arr-radarr-test" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem;">Test Connection</button>
|
||
</div>
|
||
|
||
<!-- Sonarr Config -->
|
||
<div style="margin-bottom: 12px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<span style="font-size: 1.1rem;">📺</span>
|
||
<span style="font-weight: 500;">Sonarr (TV Shows)</span>
|
||
<span id="sonarr-test-status" style="margin-left: auto; font-size: 0.75rem;"></span>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||
<input type="text" id="arr-sonarr-url" placeholder="https://seedbox.com/sonarr/" style="width: 100%; font-size: 0.85rem;" />
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||
<input type="password" id="arr-sonarr-key" placeholder="From Settings → General" style="width: 100%; font-size: 0.85rem;" />
|
||
</div>
|
||
</div>
|
||
<button id="arr-sonarr-test" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem;">Test Connection</button>
|
||
</div>
|
||
|
||
<!-- External Connect Button -->
|
||
<button id="arr-setup-run" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none; color: white; font-weight: 500; border-radius: 8px; cursor: pointer;">
|
||
⚡ Connect External Services to Overseerr
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Setup Result -->
|
||
<div id="arr-setup-result" style="display: none; margin: 16px 0; padding: 12px; border-radius: 8px;"></div>
|
||
|
||
<!-- Help Text -->
|
||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px;">
|
||
<strong>Where to find API keys:</strong><br>
|
||
• Radarr: Settings → General → Security → API Key<br>
|
||
• Sonarr: Settings → General → Security → API Key
|
||
</div>
|
||
|
||
<!-- Close Button -->
|
||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||
<button id="arr-setup-cancel">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Service Modal -->
|
||
<div id="add-service-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 600px; max-width: 800px;">
|
||
<h3>Add New Service</h3>
|
||
|
||
<!-- Service Type Selector -->
|
||
<div style="margin-bottom: 16px; padding: 12px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
|
||
<label style="font-weight: 600; color: var(--accent); margin-bottom: 8px; display: block;">Service Type:</label>
|
||
<div style="display: flex; gap: 12px;">
|
||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||
<input type="radio" name="service-type" value="local" id="service-type-local" checked />
|
||
<span>🏠 Local Service</span>
|
||
</label>
|
||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||
<input type="radio" name="service-type" value="external" id="service-type-external" />
|
||
<span>🌐 External Service</span>
|
||
</label>
|
||
</div>
|
||
<p style="font-size: 0.75rem; color: var(--muted); margin: 6px 0 0;">
|
||
<span id="service-type-description">Local: Service running on your network (Docker, VM, etc.)</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Local Service Configuration -->
|
||
<div id="local-service-config" style="display: grid; grid-template-columns: 1fr; gap: 12px;">
|
||
<!-- Service Details -->
|
||
<div>
|
||
<h4 style="margin: 0 0 8px; color: var(--accent);">Service Configuration</h4>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label for="service-name-input">Service Name:</label>
|
||
<input type="text" id="service-name-input" placeholder="e.g., Jellyfin" />
|
||
</div>
|
||
<div>
|
||
<label for="service-subdomain-input">Subdomain:</label>
|
||
<input type="text" id="service-subdomain-input" placeholder="e.g., jellyfin" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label for="service-port-input">Internal Port:</label>
|
||
<input type="number" id="service-port-input" placeholder="e.g., 8096" />
|
||
</div>
|
||
<div>
|
||
<label for="service-ip-input">Internal IP:</label>
|
||
<input type="text" id="service-ip-input" placeholder="e.g., 192.168.1.100" value="192.168.1.100" />
|
||
<div class="quick-ip-buttons" style="display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;">
|
||
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost (127.0.0.1)">localhost</button>
|
||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP">LAN</button>
|
||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP">Tailscale</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<label for="service-logo-input">Logo URL (optional):</label>
|
||
<input type="text" id="service-logo-input" placeholder="e.g., /assets/jellyfin.png" />
|
||
</div>
|
||
|
||
<!-- DNS & SSL Configuration -->
|
||
<div>
|
||
<h4 style="margin: 0 0 8px; color: var(--accent);">DNS & SSL Configuration</h4>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label>
|
||
<input type="checkbox" id="create-dns-record" checked />
|
||
Create DNS Record (DNS2)
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label for="ssl-type-select">SSL Configuration:</label>
|
||
<select id="ssl-type-select">
|
||
<option value="letsencrypt">Let's Encrypt (Auto)</option>
|
||
<option value="caddy-managed">Caddy Managed SSL</option>
|
||
<option value="existing-ca">Use Existing CA</option>
|
||
<option value="custom-ca">Custom CA (New)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="dns-config" style="margin-left: 0;">
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 8px;">
|
||
DNS credentials are managed in the Tokens menu.
|
||
</p>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label for="dns-ttl-input">DNS TTL (seconds):</label>
|
||
<input type="number" id="dns-ttl-input" value="300" />
|
||
</div>
|
||
<div>
|
||
<label for="caddyfile-path-input">Caddyfile Path:</label>
|
||
<input type="text" id="caddyfile-path-input" placeholder="e.g., C:\caddy\Caddyfile" value="C:\caddy\Caddyfile" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="existing-ca-config" style="display: none;">
|
||
<label for="existing-ca-select">Select Existing CA:</label>
|
||
<div style="display: flex; gap: 8px;">
|
||
<select id="existing-ca-select" style="flex: 1;">
|
||
<option value="">Loading CAs...</option>
|
||
</select>
|
||
<button type="button" id="refresh-cas" style="padding: 4px 8px; font-size: 0.8rem;">🔄</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="custom-ca-config" style="display: none;">
|
||
<label for="ca-name-input">New CA Name:</label>
|
||
<input type="text" id="ca-name-input" placeholder="e.g., sami-ca" />
|
||
</div>
|
||
|
||
<label>
|
||
<input type="checkbox" id="reload-caddy" checked />
|
||
Reload Caddy after adding configuration
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Tailscale Security -->
|
||
<div>
|
||
<h4 style="margin: 0 0 8px; color: var(--accent);">Tailscale Security</h4>
|
||
<div id="manual-tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem; margin-bottom: 8px;">
|
||
<span style="color: var(--muted);">Checking Tailscale status...</span>
|
||
</div>
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;">
|
||
<input type="checkbox" id="manual-tailscale-only" style="width: 18px; height: 18px;" />
|
||
<div>
|
||
<span style="font-weight: 500;">Tailscale-Only Access</span>
|
||
<div style="font-size: 0.75rem; color: var(--muted);">Restrict this service to Tailscale users only</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- External Service Configuration -->
|
||
<div id="external-service-config" style="display: none;">
|
||
<div style="background: color-mix(in srgb, var(--accent) 5%, transparent); padding: 16px; border-radius: 8px; border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);">
|
||
<h4 style="margin: 0 0 12px; color: var(--accent);">🌐 External Service Configuration</h4>
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 16px;">
|
||
Create a custom shortcut to an external service (like seedhost.eu) with your own .sami domain name.
|
||
This creates a DNS record and Caddy reverse proxy to the external URL.
|
||
</p>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr; gap: 12px;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label for="external-service-name">Display Name:</label>
|
||
<input type="text" id="external-service-name" placeholder="e.g., Radarr (Seedhost)" />
|
||
</div>
|
||
<div>
|
||
<label for="external-service-subdomain">Custom Subdomain:</label>
|
||
<input type="text" id="external-service-subdomain" placeholder="e.g., radarr-remote" />
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0;">
|
||
Will create: <span id="external-domain-preview" style="color: var(--accent); font-weight: 600;">radarr-remote.sami</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label for="external-service-url">External URL:</label>
|
||
<input type="url" id="external-service-url" placeholder="e.g., https://username.seedhost.eu/radarr" />
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0;">
|
||
Full URL including https:// and any paths
|
||
</p>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||
<div>
|
||
<label for="external-service-logo">Logo URL (optional):</label>
|
||
<input type="text" id="external-service-logo" placeholder="e.g., /assets/radarr.png" />
|
||
</div>
|
||
<div>
|
||
<label for="external-service-icon">Icon Emoji (optional):</label>
|
||
<input type="text" id="external-service-icon" placeholder="e.g., 🎬" maxlength="2" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label>
|
||
<input type="checkbox" id="external-create-dns" checked />
|
||
Create DNS Record (points to your Caddy server)
|
||
</label>
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0 24px;">
|
||
Creates DNS record pointing to your local Caddy server for reverse proxy
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label>
|
||
<input type="checkbox" id="external-create-caddy" checked />
|
||
Create Caddy Reverse Proxy
|
||
</label>
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0 24px;">
|
||
Proxies requests from your custom domain to the external URL
|
||
</p>
|
||
</div>
|
||
|
||
<details style="margin-top: 8px;">
|
||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.85rem;">⚙️ Advanced Options</summary>
|
||
<div style="margin-top: 12px; display: grid; gap: 8px;">
|
||
<div>
|
||
<label for="external-proxy-ip">Proxy Server IP:</label>
|
||
<input type="text" id="external-proxy-ip" placeholder="192.168.254.204" value="192.168.254.204" />
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0;">
|
||
IP address of your Caddy server (for DNS record)
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label>
|
||
<input type="checkbox" id="external-preserve-host" checked />
|
||
Preserve Host Header
|
||
</label>
|
||
<p style="font-size: 0.7rem; color: var(--muted); margin: 4px 0 0 24px;">
|
||
Sends original host header to external service (recommended)
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label>
|
||
<input type="checkbox" id="external-follow-redirects" checked />
|
||
Follow Redirects
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div style="background: color-mix(in srgb, var(--ok-bg) 50%, transparent); padding: 12px; border-radius: 6px; border: 1px solid var(--ok-fg); margin-top: 8px;">
|
||
<p style="font-size: 0.75rem; margin: 0; color: var(--fg);">
|
||
<strong>💡 Example Use Cases:</strong><br>
|
||
• Seedhost.eu services (Radarr, Sonarr, etc.)<br>
|
||
• Cloud-hosted apps with long URLs<br>
|
||
• Services behind authentication<br>
|
||
• Any external service you want a custom .sami shortcut for
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced Options (for local services) -->
|
||
<details id="local-advanced-options" style="margin-top: 12px;">
|
||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.9rem;">⚙️ Advanced Options</summary>
|
||
<div style="margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.8rem;">
|
||
<div>
|
||
<label style="font-size: 0.8rem;">
|
||
<input type="checkbox" id="enable-auth" />
|
||
Enable Authentication
|
||
</label>
|
||
|
||
<label style="font-size: 0.8rem;">
|
||
<input type="checkbox" id="enable-cors" />
|
||
Enable CORS Headers
|
||
</label>
|
||
|
||
<label for="upstream-path-input">Upstream Path:</label>
|
||
<input type="text" id="upstream-path-input" placeholder="/" value="/" />
|
||
</div>
|
||
|
||
<div>
|
||
<label for="health-check-input">Health Check Path:</label>
|
||
<input type="text" id="health-check-input" placeholder="/health" />
|
||
|
||
<label for="timeout-input">Timeout (seconds):</label>
|
||
<input type="number" id="timeout-input" value="30" />
|
||
|
||
<label for="custom-headers-input">Custom Headers (JSON):</label>
|
||
<textarea id="custom-headers-input" placeholder='{"X-Custom": "value"}' rows="2" style="font-size: 0.75rem;"></textarea>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="weather-modal-buttons" style="margin-top: 20px; display: flex; gap: 8px; justify-content: space-between;">
|
||
<button id="add-service-test" style="background: color-mix(in srgb, #9c27b0 20%, transparent); border-color: #9c27b0; color: #ce93d8;" title="Add a test service card without DNS/Caddy setup">Add Test Service</button>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button id="add-service-cancel">Cancel</button>
|
||
<button id="add-service-create" style="background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">Create Service</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const THEME_KEY = 'theme';
|
||
const THEMES = ['dark', 'light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light'];
|
||
|
||
/* Auto theme detection and smooth transitions */
|
||
function getSystemTheme() {
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
|
||
function applyTheme(mode) {
|
||
// Add transition class before changing theme
|
||
document.documentElement.classList.add('theme-transitioning');
|
||
|
||
// Remove all theme classes
|
||
document.documentElement.classList.remove('light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light');
|
||
|
||
// Add the selected theme class (dark is default, no class needed)
|
||
if (mode !== 'dark') {
|
||
document.documentElement.classList.add(mode);
|
||
}
|
||
|
||
localStorage.setItem(THEME_KEY, mode);
|
||
|
||
// Remove transition class after animation completes
|
||
setTimeout(() => {
|
||
document.documentElement.classList.remove('theme-transitioning');
|
||
}, 300);
|
||
}
|
||
|
||
// Initialize theme - use system preference if no saved preference
|
||
const savedTheme = localStorage.getItem(THEME_KEY);
|
||
const initialTheme = savedTheme || getSystemTheme();
|
||
applyTheme(initialTheme);
|
||
|
||
// Listen for system theme changes
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||
// Only auto-switch if user hasn't manually set a theme
|
||
if (!localStorage.getItem(THEME_KEY)) {
|
||
applyTheme(e.matches ? 'dark' : 'light');
|
||
}
|
||
});
|
||
|
||
document.getElementById('theme')?.addEventListener('click', () => {
|
||
const cur = localStorage.getItem(THEME_KEY) || getSystemTheme();
|
||
const idx = THEMES.indexOf(cur);
|
||
applyTheme(THEMES[(idx + 1) % THEMES.length]);
|
||
});
|
||
|
||
/* Enhanced status helpers with response time tracking */
|
||
function setQuick(id, up, responseTime = null) {
|
||
const dot = document.getElementById(id + '-dot');
|
||
const pill = document.getElementById(id + '-pill');
|
||
const timeEl = document.getElementById(id + '-time');
|
||
const card = document.querySelector(`[data-app="${id}"]`);
|
||
|
||
if (dot) {
|
||
dot.classList.toggle('ok', up);
|
||
dot.classList.toggle('bad', !up);
|
||
}
|
||
|
||
if (pill) {
|
||
pill.textContent = up ? 'ON' : 'OFF';
|
||
pill.classList.toggle('on', up);
|
||
pill.classList.toggle('off', !up);
|
||
}
|
||
|
||
if (timeEl && responseTime !== null) {
|
||
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
||
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
||
}
|
||
|
||
// Update card status for icon coloring
|
||
if (card) {
|
||
card.setAttribute('data-status', up ? 'on' : 'off');
|
||
}
|
||
|
||
// Internet card packet blink effect
|
||
if (id === 'internet' && dot && up) {
|
||
blinkInternetPacket(dot);
|
||
}
|
||
}
|
||
|
||
// Internet card packet activity blink
|
||
let internetBlinkInterval = null;
|
||
function blinkInternetPacket(dot) {
|
||
// Alternate between rx (green) and tx (blue) to simulate bidirectional traffic
|
||
const isRx = Math.random() > 0.5;
|
||
dot.classList.add(isRx ? 'packet-rx' : 'packet-tx');
|
||
setTimeout(() => {
|
||
dot.classList.remove('packet-rx', 'packet-tx');
|
||
}, 150);
|
||
}
|
||
|
||
// Continuous packet simulation for Internet card when online
|
||
function startInternetPacketSimulation() {
|
||
if (internetBlinkInterval) return;
|
||
internetBlinkInterval = setInterval(() => {
|
||
const dot = document.getElementById('internet-dot');
|
||
const card = document.querySelector('[data-app="internet"]');
|
||
if (dot && card && card.getAttribute('data-status') === 'on') {
|
||
blinkInternetPacket(dot);
|
||
}
|
||
}, 300 + Math.random() * 400); // Random interval 300-700ms
|
||
}
|
||
|
||
function stopInternetPacketSimulation() {
|
||
if (internetBlinkInterval) {
|
||
clearInterval(internetBlinkInterval);
|
||
internetBlinkInterval = null;
|
||
}
|
||
}
|
||
|
||
// Start simulation on page load
|
||
startInternetPacketSimulation();
|
||
|
||
function getResponseTimeClass(time, isUp) {
|
||
if (!isUp) return 'timeout';
|
||
if (time < 200) return 'excellent';
|
||
if (time < 500) return 'good';
|
||
if (time < 1000) return 'fair';
|
||
return 'slow';
|
||
}
|
||
|
||
async function checkServiceWithTiming(id) {
|
||
const startTime = performance.now();
|
||
try {
|
||
const r = await fetch('/probe/' + id, { cache: 'no-store' });
|
||
const endTime = performance.now();
|
||
const responseTime = Math.round(endTime - startTime);
|
||
const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403;
|
||
return { isUp, responseTime };
|
||
} catch {
|
||
const endTime = performance.now();
|
||
const responseTime = Math.round(endTime - startTime);
|
||
return { isUp: false, responseTime };
|
||
}
|
||
}
|
||
|
||
/* App grid - loaded from API */
|
||
window.APPS = []; // Use window.APPS as the main array
|
||
|
||
// Load services from API
|
||
async function loadServices() {
|
||
try {
|
||
const response = await fetch('/api/services');
|
||
if (response.ok) {
|
||
window.APPS = await response.json();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load services from API:', error);
|
||
}
|
||
}
|
||
|
||
function serviceUrl(id) { return `https://${id}.sami`; }
|
||
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
|
||
|
||
function buildGrid() {
|
||
const root = document.getElementById('cards'); root.innerHTML = '';
|
||
for (let i = 0; i < window.APPS.length; i++) {
|
||
const s = window.APPS[i];
|
||
const card = el('div', 'card');
|
||
card.setAttribute('data-app', s.id);
|
||
card.setAttribute('data-status', 'off'); // Initial status
|
||
|
||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||
|
||
const row = el('div', 'row');
|
||
const wrap = el('div', 'logo-wrap');
|
||
|
||
// Use reliable PNG images with automatic CDN fallback
|
||
const img = document.createElement('img');
|
||
img.src = s.logo;
|
||
img.alt = s.name;
|
||
img.className = 'logo-img';
|
||
img.onerror = function() {
|
||
console.log('Local logo failed:', s.logo, 'for service:', s.name);
|
||
|
||
// Try CDN fallback with multiple naming strategies
|
||
// Use id, appTemplate, or derive from name
|
||
let appId = s.id || s.appTemplate;
|
||
if (!appId && s.name) {
|
||
// Derive ID from name (lowercase, remove spaces)
|
||
appId = s.name.toLowerCase().replace(/\s+/g, '-');
|
||
}
|
||
|
||
if (appId) {
|
||
// Try different CDN URL formats
|
||
const cdnUrls = [
|
||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`,
|
||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`,
|
||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`,
|
||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png`
|
||
];
|
||
|
||
// Remove duplicates
|
||
const uniqueUrls = [...new Set(cdnUrls)];
|
||
|
||
// Find the next URL to try
|
||
const currentIndex = uniqueUrls.indexOf(this.src);
|
||
const nextIndex = currentIndex + 1;
|
||
|
||
if (nextIndex < uniqueUrls.length) {
|
||
console.log('Trying CDN logo:', uniqueUrls[nextIndex]);
|
||
this.src = uniqueUrls[nextIndex];
|
||
} else {
|
||
console.log('All logo sources failed for:', s.name);
|
||
this.style.display = 'none';
|
||
}
|
||
} else {
|
||
console.log('No app ID found for:', s.name);
|
||
this.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
wrap.appendChild(img);
|
||
row.appendChild(wrap);
|
||
|
||
const nameSpan = el('span', 'name', s.name);
|
||
row.appendChild(nameSpan);
|
||
|
||
// Add Tailscale badge if service is protected
|
||
if (s.tailscaleOnly) {
|
||
const tsBadge = el('span', 'ts-badge', '🔐');
|
||
tsBadge.title = 'Tailscale-only access';
|
||
tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;';
|
||
nameSpan.appendChild(tsBadge);
|
||
}
|
||
|
||
row.appendChild(el('span', 'spacer'));
|
||
|
||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||
card.appendChild(row);
|
||
|
||
// Add response time row
|
||
const responseRow = el('div', 'response-row');
|
||
const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id;
|
||
responseRow.appendChild(timeSpan);
|
||
card.appendChild(responseRow);
|
||
|
||
const btnRow = el('div', 'btn-row');
|
||
|
||
// Add logs button for services with containerIds
|
||
if (s.containerId) {
|
||
const logsBtn = el('button', 'logs-btn', '📋');
|
||
logsBtn.title = 'View container logs';
|
||
logsBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
openContainerLogsModal(s.containerId, s.name);
|
||
};
|
||
btnRow.appendChild(logsBtn);
|
||
}
|
||
|
||
// Add options button for all services except 'internet'
|
||
if (s.id !== 'internet') {
|
||
const optBtn = el('button', 'options-btn', '⚙️');
|
||
optBtn.title = 'Edit service settings';
|
||
optBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
openServiceEditModal(s);
|
||
};
|
||
btnRow.appendChild(optBtn);
|
||
}
|
||
|
||
// Add delete button for all services except Internet
|
||
if (s.id !== 'internet') {
|
||
const delBtn = el('button', 'delete-btn', '🗑️');
|
||
delBtn.title = 'Delete this service';
|
||
delBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
deleteService(s.id, s.name);
|
||
};
|
||
btnRow.appendChild(delBtn);
|
||
}
|
||
|
||
const btn = el('button', null, 'Open');
|
||
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
|
||
btnRow.appendChild(btn);
|
||
card.appendChild(btnRow);
|
||
|
||
root.appendChild(card);
|
||
|
||
// Staggered loading animation
|
||
setTimeout(() => {
|
||
card.classList.add('loaded');
|
||
}, i * 100); // 100ms delay between each card
|
||
}
|
||
}
|
||
|
||
function setBadge(id, up, responseTime = null) {
|
||
const dot = document.getElementById('dot-' + id + '-grid');
|
||
const pill = document.getElementById('badge-' + id);
|
||
const timeEl = document.getElementById('time-' + id);
|
||
const card = document.querySelector(`[data-app="${id}"]`);
|
||
|
||
if (dot) {
|
||
dot.classList.toggle('ok', up);
|
||
dot.classList.toggle('bad', !up);
|
||
}
|
||
|
||
if (pill) {
|
||
pill.textContent = up ? 'ON' : 'OFF';
|
||
pill.classList.toggle('on', up);
|
||
pill.classList.toggle('off', !up);
|
||
}
|
||
|
||
if (timeEl && responseTime !== null) {
|
||
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
||
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
||
}
|
||
|
||
// Update card status for icon coloring
|
||
if (card) {
|
||
card.setAttribute('data-status', up ? 'on' : 'off');
|
||
}
|
||
}
|
||
|
||
async function refreshAll() {
|
||
// Check top services with timing
|
||
const [dns1Result, dns2Result, internetResult] = await Promise.all([
|
||
checkServiceWithTiming('dns1'),
|
||
checkServiceWithTiming('dns2'),
|
||
checkServiceWithTiming('internet')
|
||
]);
|
||
|
||
setQuick('dns1', dns1Result.isUp, dns1Result.responseTime);
|
||
setQuick('dns2', dns2Result.isUp, dns2Result.responseTime);
|
||
setQuick('internet', internetResult.isUp, internetResult.responseTime);
|
||
|
||
// Check app services with timing
|
||
const appResults = await Promise.all(
|
||
window.APPS.map(async s => {
|
||
const result = await checkServiceWithTiming(s.id);
|
||
return { id: s.id, ...result };
|
||
})
|
||
);
|
||
|
||
appResults.forEach(result => {
|
||
setBadge(result.id, result.isUp, result.responseTime);
|
||
});
|
||
|
||
const stamp = document.getElementById('stamp');
|
||
if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString();
|
||
}
|
||
|
||
// Expose functions for App Selector
|
||
window.buildGrid = buildGrid;
|
||
window.refreshAll = refreshAll;
|
||
|
||
document.getElementById('dns1-open')?.addEventListener('click', () => window.open(serviceUrl('dns1'), '_blank', 'noopener'));
|
||
document.getElementById('dns2-open')?.addEventListener('click', () => window.open(serviceUrl('dns2'), '_blank', 'noopener'));
|
||
// Restart DNS service via Technitium API
|
||
async function restartDnsService(dnsId) {
|
||
const adminToken = getToken(dnsId, 'admin');
|
||
|
||
if (!adminToken) {
|
||
throw new Error('No Admin token configured. Click 🔑 Tokens to set up.');
|
||
}
|
||
|
||
// Technitium DNS Server API endpoint for restart
|
||
const baseUrl = `https://${dnsId}.sami/api/admin/restart`;
|
||
|
||
// Use form POST method - more reliable for cross-origin with tokens
|
||
return new Promise((resolve, reject) => {
|
||
// Create a hidden form that posts to hidden iframe
|
||
const iframeName = 'restart-frame-' + Date.now();
|
||
const iframe = document.createElement('iframe');
|
||
iframe.name = iframeName;
|
||
iframe.style.display = 'none';
|
||
document.body.appendChild(iframe);
|
||
|
||
const form = document.createElement('form');
|
||
form.method = 'POST';
|
||
form.action = baseUrl;
|
||
form.target = iframeName;
|
||
form.style.display = 'none';
|
||
|
||
// Add admin token as form field
|
||
const tokenInput = document.createElement('input');
|
||
tokenInput.type = 'hidden';
|
||
tokenInput.name = 'token';
|
||
tokenInput.value = adminToken;
|
||
form.appendChild(tokenInput);
|
||
|
||
document.body.appendChild(form);
|
||
|
||
// Set timeout - the service will restart so connection drops
|
||
const timeout = setTimeout(() => {
|
||
cleanup();
|
||
resolve({ status: 'ok', message: 'Restart initiated' });
|
||
}, 3000);
|
||
|
||
function cleanup() {
|
||
clearTimeout(timeout);
|
||
if (form.parentNode) form.parentNode.removeChild(form);
|
||
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
|
||
}
|
||
|
||
iframe.onload = () => {
|
||
// If we get a response, try to read it
|
||
try {
|
||
const content = iframe.contentDocument?.body?.textContent;
|
||
if (content) {
|
||
const result = JSON.parse(content);
|
||
cleanup();
|
||
if (result.status === 'ok') {
|
||
resolve(result);
|
||
} else {
|
||
reject(new Error(result.errorMessage || 'Restart failed'));
|
||
}
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
// Can't read cross-origin response, assume success after timeout
|
||
}
|
||
};
|
||
|
||
iframe.onerror = () => {
|
||
cleanup();
|
||
// Connection error likely means restart is happening
|
||
resolve({ status: 'ok', message: 'Restart initiated (connection closed)' });
|
||
};
|
||
|
||
// Submit the form
|
||
form.submit();
|
||
});
|
||
}
|
||
|
||
document.getElementById('dns1-restart')?.addEventListener('click', async () => {
|
||
if (confirm('Restart DNS1 service (Windows)?')) {
|
||
const btn = document.getElementById('dns1-restart');
|
||
try {
|
||
btn.textContent = '...';
|
||
btn.disabled = true;
|
||
await restartDnsService('dns1');
|
||
btn.textContent = '✓';
|
||
setTimeout(() => { btn.textContent = 'Restart'; btn.disabled = false; }, 2000);
|
||
setTimeout(refreshAll, 5000); // Wait longer for service to restart
|
||
} catch (e) {
|
||
console.error('Restart failed:', e);
|
||
alert('Restart failed: ' + e.message);
|
||
btn.textContent = 'Restart';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
});
|
||
document.getElementById('dns2-restart')?.addEventListener('click', async () => {
|
||
if (confirm('Restart DNS2 service (Linux)?')) {
|
||
const btn = document.getElementById('dns2-restart');
|
||
try {
|
||
btn.textContent = '...';
|
||
btn.disabled = true;
|
||
await restartDnsService('dns2');
|
||
btn.textContent = '✓';
|
||
setTimeout(() => { btn.textContent = 'Restart'; btn.disabled = false; }, 2000);
|
||
setTimeout(refreshAll, 5000); // Wait longer for service to restart
|
||
} catch (e) {
|
||
console.error('Restart failed:', e);
|
||
alert('Restart failed: ' + e.message);
|
||
btn.textContent = 'Restart';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
});
|
||
document.getElementById('refresh')?.addEventListener('click', refreshAll);
|
||
|
||
// Simple encryption for storing tokens
|
||
const ENCRYPTION_KEY = 'sami-dns-dashboard-2024';
|
||
|
||
function simpleEncrypt(text, key) {
|
||
if (!text) return '';
|
||
let result = '';
|
||
for (let i = 0; i < text.length; i++) {
|
||
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||
result += String.fromCharCode(charCode);
|
||
}
|
||
return btoa(result);
|
||
}
|
||
|
||
function simpleDecrypt(encryptedText, key) {
|
||
if (!encryptedText) return '';
|
||
try {
|
||
const decoded = atob(encryptedText);
|
||
let result = '';
|
||
for (let i = 0; i < decoded.length; i++) {
|
||
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||
result += String.fromCharCode(charCode);
|
||
}
|
||
return result;
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// New token management functions - now with username support
|
||
function getCredential(dnsId, tokenType, credType) {
|
||
// tokenType: 'readonly' or 'admin'
|
||
// credType: 'token' or 'username'
|
||
const encrypted = localStorage.getItem(`${dnsId}-${tokenType}-${credType}-enc`);
|
||
return simpleDecrypt(encrypted, ENCRYPTION_KEY);
|
||
}
|
||
|
||
function setCredential(dnsId, tokenType, credType, value) {
|
||
const key = `${dnsId}-${tokenType}-${credType}-enc`;
|
||
if (value) {
|
||
localStorage.setItem(key, simpleEncrypt(value, ENCRYPTION_KEY));
|
||
} else {
|
||
localStorage.removeItem(key);
|
||
}
|
||
}
|
||
|
||
// Convenience functions for tokens
|
||
function getToken(dnsId, tokenType) {
|
||
return getCredential(dnsId, tokenType, 'token');
|
||
}
|
||
|
||
function getUsername(dnsId, tokenType) {
|
||
return getCredential(dnsId, tokenType, 'username');
|
||
}
|
||
|
||
function setToken(dnsId, tokenType, token) {
|
||
setCredential(dnsId, tokenType, 'token', token);
|
||
}
|
||
|
||
function setUsername(dnsId, tokenType, username) {
|
||
setCredential(dnsId, tokenType, 'username', username);
|
||
}
|
||
|
||
function getAllCredentials() {
|
||
return {
|
||
dns1: {
|
||
readonly: { username: getUsername('dns1', 'readonly'), token: getToken('dns1', 'readonly') },
|
||
admin: { username: getUsername('dns1', 'admin'), token: getToken('dns1', 'admin') }
|
||
},
|
||
dns2: {
|
||
readonly: { username: getUsername('dns2', 'readonly'), token: getToken('dns2', 'readonly') },
|
||
admin: { username: getUsername('dns2', 'admin'), token: getToken('dns2', 'admin') }
|
||
}
|
||
};
|
||
}
|
||
|
||
function clearAllCredentials() {
|
||
['dns1', 'dns2'].forEach(dnsId => {
|
||
['readonly', 'admin'].forEach(tokenType => {
|
||
['token', 'username'].forEach(credType => {
|
||
localStorage.removeItem(`${dnsId}-${tokenType}-${credType}-enc`);
|
||
});
|
||
});
|
||
// Also clear old format tokens
|
||
localStorage.removeItem(`${dnsId}-token-enc`);
|
||
localStorage.removeItem(`${dnsId}-username-enc`);
|
||
});
|
||
}
|
||
|
||
// Legacy compatibility - getStoredCredentials returns credentials in expected format
|
||
function getStoredCredentials(dnsId) {
|
||
const readonlyToken = getToken(dnsId, 'readonly');
|
||
const readonlyUsername = getUsername(dnsId, 'readonly');
|
||
const adminToken = getToken(dnsId, 'admin');
|
||
const adminUsername = getUsername(dnsId, 'admin');
|
||
// Also check old format for backwards compatibility
|
||
const oldToken = simpleDecrypt(localStorage.getItem(`${dnsId}-token-enc`), ENCRYPTION_KEY);
|
||
const oldUsername = simpleDecrypt(localStorage.getItem(`${dnsId}-username-enc`), ENCRYPTION_KEY);
|
||
|
||
return {
|
||
username: adminUsername || readonlyUsername || oldUsername,
|
||
token: adminToken || readonlyToken || oldToken,
|
||
readonlyToken: readonlyToken || oldToken,
|
||
readonlyUsername: readonlyUsername || oldUsername,
|
||
adminToken: adminToken || oldToken,
|
||
adminUsername: adminUsername || oldUsername
|
||
};
|
||
}
|
||
|
||
// Token Management Modal handlers
|
||
document.getElementById('manage-tokens')?.addEventListener('click', () => {
|
||
const modal = document.getElementById('token-management-modal');
|
||
const creds = getAllCredentials();
|
||
|
||
// Populate fields with existing credentials (usernames and tokens)
|
||
document.getElementById('dns1-readonly-username').value = creds.dns1.readonly.username;
|
||
document.getElementById('dns1-readonly-token').value = creds.dns1.readonly.token;
|
||
document.getElementById('dns1-admin-username').value = creds.dns1.admin.username;
|
||
document.getElementById('dns1-admin-token').value = creds.dns1.admin.token;
|
||
document.getElementById('dns2-readonly-username').value = creds.dns2.readonly.username;
|
||
document.getElementById('dns2-readonly-token').value = creds.dns2.readonly.token;
|
||
document.getElementById('dns2-admin-username').value = creds.dns2.admin.username;
|
||
document.getElementById('dns2-admin-token').value = creds.dns2.admin.token;
|
||
|
||
// Clear status messages
|
||
document.getElementById('dns1-token-status').textContent = '';
|
||
document.getElementById('dns2-token-status').textContent = '';
|
||
|
||
modal.classList.add('show');
|
||
});
|
||
|
||
// Toggle token visibility
|
||
document.querySelectorAll('.token-toggle').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const targetId = btn.dataset.target;
|
||
const input = document.getElementById(targetId);
|
||
if (input.type === 'password') {
|
||
input.type = 'text';
|
||
btn.textContent = '🙈';
|
||
} else {
|
||
input.type = 'password';
|
||
btn.textContent = '👁';
|
||
}
|
||
});
|
||
});
|
||
|
||
document.getElementById('token-save')?.addEventListener('click', () => {
|
||
// Save all usernames and tokens
|
||
setUsername('dns1', 'readonly', document.getElementById('dns1-readonly-username').value.trim());
|
||
setToken('dns1', 'readonly', document.getElementById('dns1-readonly-token').value.trim());
|
||
setUsername('dns1', 'admin', document.getElementById('dns1-admin-username').value.trim());
|
||
setToken('dns1', 'admin', document.getElementById('dns1-admin-token').value.trim());
|
||
setUsername('dns2', 'readonly', document.getElementById('dns2-readonly-username').value.trim());
|
||
setToken('dns2', 'readonly', document.getElementById('dns2-readonly-token').value.trim());
|
||
setUsername('dns2', 'admin', document.getElementById('dns2-admin-username').value.trim());
|
||
setToken('dns2', 'admin', document.getElementById('dns2-admin-token').value.trim());
|
||
|
||
// Show success
|
||
document.getElementById('dns1-token-status').textContent = '✓ Saved';
|
||
document.getElementById('dns1-token-status').className = 'token-status success';
|
||
document.getElementById('dns2-token-status').textContent = '✓ Saved';
|
||
document.getElementById('dns2-token-status').className = 'token-status success';
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('token-management-modal').classList.remove('show');
|
||
}, 500);
|
||
});
|
||
|
||
document.getElementById('token-cancel')?.addEventListener('click', () => {
|
||
document.getElementById('token-management-modal').classList.remove('show');
|
||
});
|
||
|
||
document.getElementById('token-clear-all')?.addEventListener('click', () => {
|
||
if (confirm('Clear all stored API credentials? This cannot be undone.')) {
|
||
clearAllCredentials();
|
||
// Clear all username and token fields
|
||
document.getElementById('dns1-readonly-username').value = '';
|
||
document.getElementById('dns1-readonly-token').value = '';
|
||
document.getElementById('dns1-admin-username').value = '';
|
||
document.getElementById('dns1-admin-token').value = '';
|
||
document.getElementById('dns2-readonly-username').value = '';
|
||
document.getElementById('dns2-readonly-token').value = '';
|
||
document.getElementById('dns2-admin-username').value = '';
|
||
document.getElementById('dns2-admin-token').value = '';
|
||
document.getElementById('dns1-token-status').textContent = '✓ Cleared';
|
||
document.getElementById('dns1-token-status').className = 'token-status success';
|
||
document.getElementById('dns2-token-status').textContent = '✓ Cleared';
|
||
document.getElementById('dns2-token-status').className = 'token-status success';
|
||
}
|
||
});
|
||
|
||
// Close modal on backdrop click
|
||
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'token-management-modal') {
|
||
e.target.classList.remove('show');
|
||
}
|
||
});
|
||
|
||
// DNS Logs functionality
|
||
let currentDnsService = null;
|
||
let logsInterval = null;
|
||
let logsPaused = false;
|
||
|
||
// Container Logs functionality
|
||
let currentContainerId = null;
|
||
let currentContainerName = null;
|
||
let containerLogsMode = false;
|
||
|
||
async function fetchDnsLogs(dnsId, lines = 25) {
|
||
// Use read-only token for logs, fall back to admin token if readonly not set
|
||
const readonlyToken = getToken(dnsId, 'readonly') || getToken(dnsId, 'admin');
|
||
|
||
try {
|
||
if (!readonlyToken) {
|
||
return { error: `No API token configured for ${dnsId}. Click 🔑 Tokens to configure.` };
|
||
}
|
||
|
||
// Map DNS ID to IP address
|
||
const dnsServerIPs = {
|
||
'dns1': '192.168.254.204:5380',
|
||
'dns2': '100.74.102.61:5380'
|
||
};
|
||
const serverIP = dnsServerIPs[dnsId] || `${dnsId}.sami`;
|
||
|
||
const endpoint = `/api/dns/logs?server=${serverIP}&token=${encodeURIComponent(readonlyToken)}&limit=${lines}`;
|
||
|
||
const response = await fetch(endpoint, {
|
||
cache: 'no-store',
|
||
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.success && result.logs) {
|
||
return { logs: result.logs, count: result.count, server: result.server };
|
||
} else {
|
||
return { error: result.error || 'Failed to fetch logs' };
|
||
}
|
||
} else if (response.status === 401) {
|
||
return { error: 'Authentication failed - check token in 🔑 Tokens' };
|
||
} else {
|
||
return { error: `HTTP ${response.status}` };
|
||
}
|
||
} catch (error) {
|
||
console.error('DNS logs fetch failed:', error);
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
function getRcodeColor(rcode) {
|
||
const colors = {
|
||
'NoError': 'var(--ok-fg)',
|
||
'NOERROR': 'var(--ok-fg)',
|
||
'NxDomain': 'var(--muted)',
|
||
'NXDOMAIN': 'var(--muted)',
|
||
'Refused': 'var(--bad-fg)',
|
||
'REFUSED': 'var(--bad-fg)',
|
||
'ServerFailure': '#f39c12',
|
||
'SERVFAIL': '#f39c12'
|
||
};
|
||
return colors[rcode] || 'var(--fg)';
|
||
}
|
||
|
||
function renderDnsLogEntry(log) {
|
||
const div = document.createElement('div');
|
||
div.className = 'log-entry';
|
||
div.style.cssText = 'display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;';
|
||
|
||
// If unparsed raw log
|
||
if (log.parsed === false) {
|
||
div.style.gridTemplateColumns = '1fr';
|
||
div.innerHTML = `<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${log.raw}</span>`;
|
||
return div;
|
||
}
|
||
|
||
const rcodeColor = getRcodeColor(log.rcode);
|
||
const isBlocked = log.rcode === 'Refused' || log.rcode === 'REFUSED';
|
||
|
||
div.innerHTML = `
|
||
<span style="color: var(--muted); font-size: 0.75rem;">${log.timestamp}</span>
|
||
<span style="color: var(--accent); font-size: 0.75rem;" title="${log.client}">${log.client}</span>
|
||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${isBlocked ? 'text-decoration: line-through; opacity: 0.6;' : ''}" title="${log.domain}">${log.domain}</span>
|
||
<span style="color: var(--muted); font-size: 0.75rem;">${log.type}</span>
|
||
<span style="color: ${rcodeColor}; font-weight: 500; font-size: 0.75rem;">${log.rcode}</span>
|
||
`;
|
||
|
||
return div;
|
||
}
|
||
|
||
async function updateLogsDisplay() {
|
||
// Handle container logs mode
|
||
if (containerLogsMode) {
|
||
await updateContainerLogsDisplay();
|
||
return;
|
||
}
|
||
|
||
// Handle DNS logs mode
|
||
if (logsPaused || !currentDnsService) return;
|
||
|
||
const lines = parseInt(document.getElementById('log-lines').value);
|
||
const logsContent = document.getElementById('logs-content');
|
||
|
||
try {
|
||
const result = await fetchDnsLogs(currentDnsService, lines);
|
||
|
||
if (result.error) {
|
||
logsContent.innerHTML = `
|
||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||
<div>${result.error}</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Add header row
|
||
logsContent.innerHTML = `
|
||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||
<span>Time</span>
|
||
<span>Client</span>
|
||
<span>Domain</span>
|
||
<span>Type</span>
|
||
<span>Status</span>
|
||
</div>`;
|
||
|
||
if (result.logs && result.logs.length > 0) {
|
||
result.logs.forEach(log => {
|
||
const logElement = renderDnsLogEntry(log);
|
||
logsContent.appendChild(logElement);
|
||
});
|
||
} else {
|
||
logsContent.innerHTML += `
|
||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||
No DNS queries logged yet
|
||
</div>`;
|
||
}
|
||
} catch (error) {
|
||
logsContent.innerHTML = `
|
||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||
Failed to fetch logs: ${error.message}
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function openLogsModal(dnsId) {
|
||
currentDnsService = dnsId;
|
||
logsPaused = false;
|
||
|
||
const modal = document.getElementById('logs-modal');
|
||
const title = document.getElementById('logs-title');
|
||
const pauseBtn = document.getElementById('logs-pause');
|
||
|
||
title.textContent = `${dnsId.toUpperCase()} DNS Logs`;
|
||
pauseBtn.textContent = '⏸️ Pause';
|
||
pauseBtn.classList.remove('paused');
|
||
|
||
modal.classList.add('show');
|
||
|
||
// Initial load
|
||
updateLogsDisplay();
|
||
|
||
// Start auto-refresh every 3 seconds
|
||
logsInterval = setInterval(updateLogsDisplay, 3000);
|
||
}
|
||
|
||
function closeLogsModal() {
|
||
const modal = document.getElementById('logs-modal');
|
||
modal.classList.remove('show');
|
||
|
||
if (logsInterval) {
|
||
clearInterval(logsInterval);
|
||
logsInterval = null;
|
||
}
|
||
|
||
// Reset all log modes
|
||
currentDnsService = null;
|
||
containerLogsMode = false;
|
||
currentContainerId = null;
|
||
currentContainerName = null;
|
||
logsPaused = false;
|
||
}
|
||
|
||
// Event listeners for DNS logs
|
||
document.getElementById('dns1-logs')?.addEventListener('click', () => openLogsModal('dns1'));
|
||
document.getElementById('dns2-logs')?.addEventListener('click', () => openLogsModal('dns2'));
|
||
|
||
document.getElementById('logs-close')?.addEventListener('click', closeLogsModal);
|
||
|
||
document.getElementById('logs-pause')?.addEventListener('click', () => {
|
||
logsPaused = !logsPaused;
|
||
const pauseBtn = document.getElementById('logs-pause');
|
||
|
||
if (logsPaused) {
|
||
pauseBtn.textContent = '▶️ Resume';
|
||
pauseBtn.classList.add('paused');
|
||
} else {
|
||
pauseBtn.textContent = '⏸️ Pause';
|
||
pauseBtn.classList.remove('paused');
|
||
updateLogsDisplay();
|
||
}
|
||
});
|
||
|
||
document.getElementById('log-lines')?.addEventListener('change', () => {
|
||
if (!logsPaused) {
|
||
updateLogsDisplay();
|
||
}
|
||
});
|
||
|
||
// DNS Settings buttons now open token management modal
|
||
function openTokenManagement() {
|
||
document.getElementById('manage-tokens')?.click();
|
||
}
|
||
|
||
// Event listeners for DNS settings (redirect to token management)
|
||
document.getElementById('dns1-settings')?.addEventListener('click', openTokenManagement);
|
||
document.getElementById('dns2-settings')?.addEventListener('click', openTokenManagement);
|
||
|
||
// Close modal on outside click
|
||
document.getElementById('logs-modal')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'logs-modal') {
|
||
closeLogsModal();
|
||
}
|
||
});
|
||
|
||
// Close modal on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
if (document.getElementById('logs-modal')?.classList.contains('show')) {
|
||
closeLogsModal();
|
||
}
|
||
if (document.getElementById('token-management-modal')?.classList.contains('show')) {
|
||
document.getElementById('token-management-modal').classList.remove('show');
|
||
}
|
||
if (document.getElementById('add-service-modal')?.classList.contains('show')) {
|
||
closeAddServiceModal();
|
||
}
|
||
}
|
||
});
|
||
|
||
// ===== CONTAINER LOGS FUNCTIONALITY =====
|
||
|
||
async function fetchContainerLogs(containerId, lines = 100) {
|
||
try {
|
||
const endpoint = `/api/logs/container/${containerId}?tail=${lines}×tamps=true`;
|
||
|
||
const response = await fetch(endpoint, {
|
||
cache: 'no-store',
|
||
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.success && result.logs) {
|
||
return {
|
||
logs: result.logs,
|
||
count: result.count,
|
||
containerName: result.containerName,
|
||
containerId: result.containerId
|
||
};
|
||
} else {
|
||
return { error: result.error || 'Failed to fetch container logs' };
|
||
}
|
||
} else {
|
||
return { error: `HTTP ${response.status}: ${response.statusText}` };
|
||
}
|
||
} catch (error) {
|
||
console.error('Container logs fetch failed:', error);
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
function renderContainerLogEntry(log) {
|
||
const div = document.createElement('div');
|
||
div.className = 'log-entry';
|
||
div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
|
||
|
||
const streamColor = log.stream === 'stderr' ? 'var(--bad-fg)' : 'var(--fg)';
|
||
const streamBadge = log.stream === 'stderr' ?
|
||
'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>' :
|
||
'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';
|
||
|
||
div.innerHTML = `
|
||
<div style="flex-shrink: 0;">${streamBadge}</div>
|
||
<div style="flex: 1; color: ${streamColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(log.text)}</div>
|
||
`;
|
||
|
||
return div;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function updateContainerLogsDisplay() {
|
||
if (logsPaused || !currentContainerId || !containerLogsMode) return;
|
||
|
||
const lines = parseInt(document.getElementById('log-lines').value);
|
||
const logsContent = document.getElementById('logs-content');
|
||
|
||
try {
|
||
const result = await fetchContainerLogs(currentContainerId, lines);
|
||
|
||
if (result.error) {
|
||
logsContent.innerHTML = `
|
||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||
<div>${result.error}</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Add header row
|
||
logsContent.innerHTML = `
|
||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||
<span style="flex-shrink: 0; width: 80px;">Stream</span>
|
||
<span style="flex: 1;">Log Output</span>
|
||
</div>`;
|
||
|
||
if (result.logs && result.logs.length > 0) {
|
||
result.logs.forEach(log => {
|
||
const logElement = renderContainerLogEntry(log);
|
||
logsContent.appendChild(logElement);
|
||
});
|
||
|
||
// Auto-scroll to bottom
|
||
logsContent.scrollTop = logsContent.scrollHeight;
|
||
} else {
|
||
logsContent.innerHTML += `
|
||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||
No logs available for this container
|
||
</div>`;
|
||
}
|
||
} catch (error) {
|
||
logsContent.innerHTML = `
|
||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||
Failed to fetch logs: ${error.message}
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function openContainerLogsModal(containerId, containerName) {
|
||
currentContainerId = containerId;
|
||
currentContainerName = containerName;
|
||
containerLogsMode = true;
|
||
logsPaused = false;
|
||
|
||
const modal = document.getElementById('logs-modal');
|
||
const title = document.getElementById('logs-title');
|
||
const pauseBtn = document.getElementById('logs-pause');
|
||
|
||
title.textContent = `📋 ${containerName} - Container Logs`;
|
||
pauseBtn.textContent = '⏸️ Pause';
|
||
pauseBtn.classList.remove('paused');
|
||
|
||
modal.classList.add('show');
|
||
|
||
// Initial load
|
||
updateContainerLogsDisplay();
|
||
|
||
// Start auto-refresh every 3 seconds
|
||
logsInterval = setInterval(updateContainerLogsDisplay, 3000);
|
||
}
|
||
|
||
// Service Management System
|
||
let serviceManagementCredentials = {
|
||
dnsToken: '',
|
||
dnsUsername: ''
|
||
};
|
||
|
||
// Load DNS management credentials
|
||
function loadDnsManagementCredentials() {
|
||
const encryptedToken = localStorage.getItem('dns-management-token-enc');
|
||
const encryptedUsername = localStorage.getItem('dns-management-username-enc');
|
||
|
||
serviceManagementCredentials.dnsToken = encryptedToken ? simpleDecrypt(encryptedToken, ENCRYPTION_KEY) : '';
|
||
serviceManagementCredentials.dnsUsername = encryptedUsername ? simpleDecrypt(encryptedUsername, ENCRYPTION_KEY) : '';
|
||
}
|
||
|
||
// Save DNS management credentials
|
||
function saveDnsManagementCredentials(username, token) {
|
||
const encryptedUsername = simpleEncrypt(username, ENCRYPTION_KEY);
|
||
const encryptedToken = simpleEncrypt(token, ENCRYPTION_KEY);
|
||
|
||
localStorage.setItem('dns-management-username-enc', encryptedUsername);
|
||
localStorage.setItem('dns-management-token-enc', encryptedToken);
|
||
|
||
serviceManagementCredentials.dnsUsername = username;
|
||
serviceManagementCredentials.dnsToken = token;
|
||
}
|
||
|
||
// Load existing CAs from Caddyfile
|
||
async function loadExistingCAs(caddyfilePath) {
|
||
try {
|
||
const response = await fetch(`/api/caddy/get-cas?caddyfilePath=${encodeURIComponent(caddyfilePath)}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load CAs: ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
const select = document.getElementById('existing-ca-select');
|
||
select.innerHTML = '';
|
||
|
||
if (result.data.cas.length === 0) {
|
||
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
|
||
} else {
|
||
select.innerHTML = '<option value="">Select existing CA...</option>';
|
||
result.data.cas.forEach(ca => {
|
||
const option = document.createElement('option');
|
||
// Handle both object format {id, name, displayName} and string format
|
||
if (typeof ca === 'object') {
|
||
option.value = ca.id;
|
||
option.textContent = ca.displayName || ca.name;
|
||
} else {
|
||
option.value = ca;
|
||
option.textContent = ca;
|
||
}
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
console.log('Loaded CAs:', result.data.cas);
|
||
return result.data.cas;
|
||
} else {
|
||
throw new Error(result.message);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading CAs:', error);
|
||
const select = document.getElementById('existing-ca-select');
|
||
select.innerHTML = '<option value="">Error loading CAs</option>';
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Generate Caddy configuration
|
||
function generateCaddyConfig(config) {
|
||
const {
|
||
subdomain,
|
||
port,
|
||
ip,
|
||
sslType,
|
||
caName,
|
||
existingCa,
|
||
enableAuth,
|
||
enableCors,
|
||
customHeaders,
|
||
upstreamPath,
|
||
healthCheck,
|
||
timeout,
|
||
tailscaleOnly
|
||
} = config;
|
||
|
||
let caddyConfig = `${subdomain}.sami {\n`;
|
||
|
||
// Tailscale-only access restriction
|
||
if (tailscaleOnly) {
|
||
caddyConfig += ` @blocked not remote_ip 100.64.0.0/10\n`;
|
||
caddyConfig += ` respond @blocked "Access denied. Tailscale connection required." 403\n`;
|
||
}
|
||
|
||
// SSL Configuration
|
||
switch (sslType) {
|
||
case 'letsencrypt':
|
||
// Let's Encrypt is default, no additional config needed
|
||
break;
|
||
case 'caddy-managed':
|
||
caddyConfig += ` tls internal\n`;
|
||
break;
|
||
case 'existing-ca':
|
||
if (existingCa) {
|
||
caddyConfig += ` tls {\n ca ${existingCa}\n }\n`;
|
||
}
|
||
break;
|
||
case 'custom-ca':
|
||
if (caName) {
|
||
caddyConfig += ` tls {\n ca ${caName}\n }\n`;
|
||
}
|
||
break;
|
||
}
|
||
|
||
// Authentication
|
||
if (enableAuth) {
|
||
caddyConfig += ` basicauth {\n admin $2a$14$hashed_password_here\n }\n`;
|
||
}
|
||
|
||
// CORS Headers
|
||
if (enableCors) {
|
||
caddyConfig += ` header {\n`;
|
||
caddyConfig += ` Access-Control-Allow-Origin "*"\n`;
|
||
caddyConfig += ` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"\n`;
|
||
caddyConfig += ` Access-Control-Allow-Headers "Content-Type, Authorization"\n`;
|
||
caddyConfig += ` }\n`;
|
||
}
|
||
|
||
// Custom Headers
|
||
if (customHeaders) {
|
||
try {
|
||
const headers = JSON.parse(customHeaders);
|
||
caddyConfig += ` header {\n`;
|
||
Object.entries(headers).forEach(([key, value]) => {
|
||
caddyConfig += ` ${key} "${value}"\n`;
|
||
});
|
||
caddyConfig += ` }\n`;
|
||
} catch (e) {
|
||
console.warn('Invalid JSON in custom headers');
|
||
}
|
||
}
|
||
|
||
// Health Check
|
||
if (healthCheck) {
|
||
caddyConfig += ` health_uri ${healthCheck}\n`;
|
||
}
|
||
|
||
// Reverse Proxy
|
||
caddyConfig += ` reverse_proxy ${ip}:${port} {\n`;
|
||
if (upstreamPath && upstreamPath !== '/') {
|
||
caddyConfig += ` rewrite ${upstreamPath}\n`;
|
||
}
|
||
if (timeout && timeout !== 30) {
|
||
caddyConfig += ` transport http {\n`;
|
||
caddyConfig += ` dial_timeout ${timeout}s\n`;
|
||
caddyConfig += ` response_header_timeout ${timeout}s\n`;
|
||
caddyConfig += ` }\n`;
|
||
}
|
||
caddyConfig += ` }\n`;
|
||
|
||
caddyConfig += `}\n`;
|
||
|
||
return caddyConfig;
|
||
}
|
||
|
||
// Create DNS record via Technitium API (proxied through Docker API to avoid CORS)
|
||
async function createDnsRecord(subdomain, ip, ttl = 300) {
|
||
// Get DNS admin token from token management
|
||
const dnsToken = getToken('dns2', 'admin');
|
||
|
||
if (!dnsToken) {
|
||
throw new Error('DNS admin token not configured. Please set it in the Tokens menu.');
|
||
}
|
||
|
||
const domain = `${subdomain}.sami`;
|
||
|
||
// Use proxied endpoint through Docker API to avoid CORS issues
|
||
// Use IP instead of hostname since Docker container can't resolve dns2.sami
|
||
const response = await fetch('/api/dns/record', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
domain: domain,
|
||
ip: ip,
|
||
ttl: ttl,
|
||
token: dnsToken,
|
||
server: '100.74.102.61' // dns2 Tailscale IP
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`DNS API Error: ${response.status} - ${errorText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(`DNS Error: ${result.error || 'Unknown error'}`);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// Add service to persistent storage via API
|
||
async function addServiceToConfig(serviceConfig) {
|
||
const newService = {
|
||
id: serviceConfig.subdomain,
|
||
name: serviceConfig.name,
|
||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||
};
|
||
|
||
try {
|
||
const response = await fetch('/api/services', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(newService)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to save service');
|
||
}
|
||
|
||
// Reload services from API and rebuild grid
|
||
await loadServices();
|
||
buildGrid();
|
||
|
||
return newService;
|
||
} catch (error) {
|
||
console.error('Failed to add service to config:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ===== SERVICE EDIT MODAL =====
|
||
let currentEditService = null;
|
||
|
||
function openServiceEditModal(service) {
|
||
currentEditService = service;
|
||
const modal = document.getElementById('service-edit-modal');
|
||
|
||
// Populate fields
|
||
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
|
||
document.getElementById('edit-service-name-display').textContent = service.name;
|
||
document.getElementById('edit-service-url-display').textContent = `https://${service.id}.sami`;
|
||
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
|
||
document.getElementById('edit-subdomain').value = service.id;
|
||
document.getElementById('edit-port').value = service.port || '';
|
||
document.getElementById('edit-ip').value = service.ip || 'localhost';
|
||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||
document.getElementById('edit-logo-url').value = service.logo || '';
|
||
|
||
modal.classList.add('show');
|
||
}
|
||
|
||
function closeServiceEditModal() {
|
||
document.getElementById('service-edit-modal').classList.remove('show');
|
||
currentEditService = null;
|
||
}
|
||
|
||
async function saveServiceChanges() {
|
||
if (!currentEditService) return;
|
||
|
||
const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase();
|
||
const newPort = document.getElementById('edit-port').value.trim();
|
||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||
|
||
if (!newSubdomain) {
|
||
alert('Subdomain is required');
|
||
return;
|
||
}
|
||
|
||
const oldSubdomain = currentEditService.id;
|
||
const changes = [];
|
||
|
||
// Track what changed
|
||
if (newSubdomain !== oldSubdomain) changes.push('subdomain');
|
||
if (newPort && newPort !== String(currentEditService.port)) changes.push('port');
|
||
if (newIp !== currentEditService.ip) changes.push('ip');
|
||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||
if (newLogo !== currentEditService.logo) changes.push('logo');
|
||
|
||
if (changes.length === 0) {
|
||
closeServiceEditModal();
|
||
return;
|
||
}
|
||
|
||
const saveBtn = document.getElementById('service-edit-save');
|
||
saveBtn.textContent = 'Saving...';
|
||
saveBtn.disabled = true;
|
||
|
||
try {
|
||
// If subdomain, port, IP, or tailscale changed, update Caddy config
|
||
if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) {
|
||
// Call API to update service
|
||
const response = await fetch('/api/services/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
oldSubdomain,
|
||
newSubdomain,
|
||
port: newPort || currentEditService.port,
|
||
ip: newIp,
|
||
tailscaleOnly
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(result.error || 'Failed to update service');
|
||
}
|
||
}
|
||
|
||
// Update local APPS array
|
||
const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain);
|
||
if (appIndex !== -1) {
|
||
window.APPS[appIndex] = {
|
||
...window.APPS[appIndex],
|
||
id: newSubdomain,
|
||
port: newPort || window.APPS[appIndex].port,
|
||
ip: newIp,
|
||
tailscaleOnly,
|
||
logo: newLogo || window.APPS[appIndex].logo
|
||
};
|
||
}
|
||
|
||
// Update services via API
|
||
await fetch('/api/services', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: newSubdomain,
|
||
name: currentEditService.name,
|
||
port: newPort || currentEditService.port,
|
||
ip: newIp,
|
||
logo: newLogo || currentEditService.logo,
|
||
tailscaleOnly,
|
||
containerId: currentEditService.containerId,
|
||
appTemplate: currentEditService.appTemplate
|
||
})
|
||
});
|
||
|
||
// If subdomain changed, remove old entry
|
||
if (newSubdomain !== oldSubdomain) {
|
||
await fetch(`/api/services/${oldSubdomain}`, { method: 'DELETE' });
|
||
}
|
||
|
||
closeServiceEditModal();
|
||
buildGrid();
|
||
refreshAll();
|
||
|
||
// Show success message
|
||
const changesText = changes.join(', ');
|
||
console.log(`Service updated: ${changesText}`);
|
||
|
||
} catch (error) {
|
||
console.error('Error saving service changes:', error);
|
||
alert(`Error saving changes: ${error.message}`);
|
||
} finally {
|
||
saveBtn.textContent = 'Save Changes';
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Logo file upload handler
|
||
document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
// Validate file type
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('Please select an image file');
|
||
return;
|
||
}
|
||
|
||
// Convert to base64 data URL for preview and storage
|
||
const reader = new FileReader();
|
||
reader.onload = async (event) => {
|
||
const dataUrl = event.target.result;
|
||
|
||
// Update preview
|
||
document.getElementById('edit-service-logo-preview').src = dataUrl;
|
||
document.getElementById('edit-logo-url').value = dataUrl;
|
||
|
||
// Optionally save to assets folder via API
|
||
if (currentEditService) {
|
||
try {
|
||
const response = await fetch('/api/assets/upload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: `${currentEditService.id}.png`,
|
||
data: dataUrl
|
||
})
|
||
});
|
||
const result = await response.json();
|
||
if (result.success && result.path) {
|
||
document.getElementById('edit-logo-url').value = result.path;
|
||
}
|
||
} catch (err) {
|
||
console.log('Could not save to assets, using data URL instead');
|
||
}
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
// Service edit modal event listeners
|
||
document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal);
|
||
document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges);
|
||
document.getElementById('service-edit-modal')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'service-edit-modal') closeServiceEditModal();
|
||
});
|
||
|
||
// Delete a service
|
||
async function deleteService(serviceId, serviceName) {
|
||
// Find the service to get its data
|
||
const service = window.APPS.find(app => app.id === serviceId);
|
||
const domain = service ? `${service.id}.sami` : null;
|
||
|
||
const deleteOptions = domain ?
|
||
`Delete service "${serviceName || serviceId}"?\n\nThis will:\n✓ Remove the card from dashboard\n✓ Remove Docker container\n✓ Remove DNS record for ${domain}\n✓ Remove Caddy config for ${domain}` :
|
||
`Delete service "${serviceName || serviceId}"?\n\nThis will remove the card from the dashboard.`;
|
||
|
||
if (!confirm(deleteOptions)) {
|
||
return;
|
||
}
|
||
|
||
let results = {
|
||
dashboard: false,
|
||
container: null,
|
||
dns: null,
|
||
caddy: null,
|
||
service: null
|
||
};
|
||
|
||
// If this was deployed via app templates, use comprehensive removal endpoint
|
||
if (service?.containerId) {
|
||
try {
|
||
const params = new URLSearchParams({
|
||
containerId: service.containerId,
|
||
subdomain: service.id,
|
||
ip: service.ip || 'localhost'
|
||
});
|
||
|
||
const response = await fetch(`/api/apps/${encodeURIComponent(service.id)}?${params.toString()}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
results = { ...results, ...result.results, dashboard: false };
|
||
} else {
|
||
console.error('App removal failed:', result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('App removal error:', error);
|
||
}
|
||
} else if (domain) {
|
||
// Fallback to individual API calls for manually added services
|
||
try {
|
||
// Don't pass token - let API use its auto-refreshed token
|
||
const serviceIP = service?.ip || 'localhost';
|
||
const dnsResponse = await fetch(`/api/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=100.74.102.61`, {
|
||
method: 'DELETE'
|
||
});
|
||
const dnsResult = await dnsResponse.json();
|
||
results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed');
|
||
} catch (e) {
|
||
results.dns = e.message;
|
||
}
|
||
|
||
try {
|
||
const caddyResponse = await fetch(`/api/site/${encodeURIComponent(domain)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const caddyResult = await caddyResponse.json();
|
||
results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed');
|
||
} catch (e) {
|
||
results.caddy = e.message;
|
||
}
|
||
}
|
||
|
||
// Remove from APPS array
|
||
const index = window.APPS.findIndex(app => app.id === serviceId);
|
||
if (index > -1) {
|
||
window.APPS.splice(index, 1);
|
||
results.dashboard = true;
|
||
}
|
||
|
||
// Remove from services.json via API
|
||
try {
|
||
const serviceResponse = await fetch(`/api/services/${encodeURIComponent(serviceId)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const serviceResult = await serviceResponse.json();
|
||
results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed');
|
||
} catch (e) {
|
||
results.service = e.message;
|
||
}
|
||
|
||
// Rebuild grid and refresh
|
||
buildGrid();
|
||
refreshAll();
|
||
|
||
// Only show alert if there are actual errors
|
||
let hasErrors = false;
|
||
let errorMessages = [];
|
||
|
||
if (!results.dashboard) {
|
||
hasErrors = true;
|
||
errorMessages.push('✗ Failed to remove from dashboard');
|
||
}
|
||
|
||
if (results.container && results.container !== 'removed' && results.container !== 'already removed' && results.container !== 'not found') {
|
||
hasErrors = true;
|
||
errorMessages.push(`⚠ Container: ${results.container}`);
|
||
}
|
||
|
||
if (results.dns && results.dns !== 'not found' && results.dns !== 'deleted') {
|
||
hasErrors = true;
|
||
errorMessages.push(`⚠ DNS Record: ${results.dns}`);
|
||
}
|
||
|
||
if (results.caddy && results.caddy !== 'not found' && results.caddy !== 'removed') {
|
||
hasErrors = true;
|
||
errorMessages.push(`⚠ Caddy Config: ${results.caddy}`);
|
||
}
|
||
|
||
if (results.service && results.service !== 'not found' && results.service !== 'removed') {
|
||
hasErrors = true;
|
||
errorMessages.push(`⚠ Service File: ${results.service}`);
|
||
}
|
||
|
||
// Only show alert if there were errors
|
||
if (hasErrors) {
|
||
let summary = `Error deleting "${serviceName || serviceId}":\n\n`;
|
||
summary += errorMessages.join('\n');
|
||
alert(summary);
|
||
}
|
||
// Otherwise, silent success - card just disappears
|
||
}
|
||
|
||
// Update preview in real-time
|
||
function updateServicePreview() {
|
||
const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain';
|
||
const ip = document.getElementById('service-ip-input').value || '192.168.1.100';
|
||
const port = document.getElementById('service-port-input').value || '8080';
|
||
const sslType = document.getElementById('ssl-type-select').value;
|
||
const caName = document.getElementById('ca-name-input').value || 'sami-ca';
|
||
const existingCa = document.getElementById('existing-ca-select').value;
|
||
const enableAuth = document.getElementById('enable-auth').checked;
|
||
const enableCors = document.getElementById('enable-cors').checked;
|
||
const customHeaders = document.getElementById('custom-headers-input').value;
|
||
const upstreamPath = document.getElementById('upstream-path-input').value || '/';
|
||
const healthCheck = document.getElementById('health-check-input').value;
|
||
const timeout = document.getElementById('timeout-input').value || 30;
|
||
|
||
// Update DNS preview
|
||
document.getElementById('dns-preview').textContent = `${subdomain}.sami → ${ip}`;
|
||
|
||
// Update URL preview
|
||
document.getElementById('url-preview').textContent = `https://${subdomain}.sami`;
|
||
|
||
// Update Caddy config preview
|
||
const config = {
|
||
subdomain,
|
||
port,
|
||
ip,
|
||
sslType,
|
||
caName,
|
||
existingCa,
|
||
enableAuth,
|
||
enableCors,
|
||
customHeaders,
|
||
upstreamPath,
|
||
healthCheck,
|
||
timeout
|
||
};
|
||
|
||
const caddyConfig = generateCaddyConfig(config);
|
||
document.getElementById('caddy-config-preview').value = caddyConfig;
|
||
}
|
||
|
||
// Quick IP configuration - set your network IPs here
|
||
const QUICK_IPS = {
|
||
localhost: '127.0.0.1',
|
||
lan: '192.168.1.100', // Your LAN IP - update this
|
||
tailscale: '100.64.0.1' // Your Tailscale IP - update this
|
||
};
|
||
|
||
// Try to detect IPs automatically via API (if available)
|
||
async function detectNetworkIPs() {
|
||
try {
|
||
const response = await fetch('/api/network/ips', {
|
||
signal: AbortSignal.timeout(2000)
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.lan) QUICK_IPS.lan = data.lan;
|
||
if (data.tailscale) QUICK_IPS.tailscale = data.tailscale;
|
||
}
|
||
} catch (e) {
|
||
// API not available, use defaults
|
||
console.log('Network IP detection not available, using defaults');
|
||
}
|
||
|
||
// Update button labels with IPs
|
||
const lanBtn = document.getElementById('quick-ip-lan');
|
||
const tsBtn = document.getElementById('quick-ip-tailscale');
|
||
if (lanBtn) {
|
||
lanBtn.dataset.ip = QUICK_IPS.lan;
|
||
lanBtn.textContent = `LAN (${QUICK_IPS.lan})`;
|
||
lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`;
|
||
}
|
||
if (tsBtn) {
|
||
tsBtn.dataset.ip = QUICK_IPS.tailscale;
|
||
tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`;
|
||
tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`;
|
||
}
|
||
}
|
||
|
||
// Initialize quick IP buttons
|
||
function initQuickIPButtons() {
|
||
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const ip = btn.dataset.ip;
|
||
if (ip) {
|
||
document.getElementById('service-ip-input').value = ip;
|
||
// Update active state
|
||
document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
updateServicePreview();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Update active state when IP input changes manually
|
||
document.getElementById('service-ip-input')?.addEventListener('input', (e) => {
|
||
const currentIP = e.target.value;
|
||
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.ip === currentIP);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Open Add Service Modal
|
||
async function openAddServiceModal() {
|
||
const modal = document.getElementById('add-service-modal');
|
||
modal.classList.add('show');
|
||
|
||
// Reset scroll position of modal content
|
||
const modalContent = modal.querySelector('.weather-modal-content');
|
||
if (modalContent) {
|
||
modalContent.scrollTop = 0;
|
||
}
|
||
|
||
// Prevent body scrolling while modal is open
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Detect network IPs and update buttons
|
||
await detectNetworkIPs();
|
||
|
||
// Load existing CAs
|
||
const caddyfilePath = document.getElementById('caddyfile-path-input').value || 'C:\\caddy\\Caddyfile';
|
||
await loadExistingCAs(caddyfilePath);
|
||
|
||
// Check Tailscale status
|
||
const tailscaleStatus = document.getElementById('manual-tailscale-status');
|
||
const tailscaleCheckbox = document.getElementById('manual-tailscale-only');
|
||
try {
|
||
const response = await fetch('/api/tailscale/status');
|
||
const data = await response.json();
|
||
if (data.success && data.installed && data.connected) {
|
||
tailscaleStatus.innerHTML = `
|
||
<span style="color: #4caf50;">✓ Tailscale connected</span>
|
||
<span style="color: var(--muted); margin-left: 8px;">${data.self?.hostname} (${data.self?.ip})</span>
|
||
`;
|
||
tailscaleCheckbox.disabled = false;
|
||
} else if (data.installed) {
|
||
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">⚠ Tailscale installed but not connected</span>`;
|
||
tailscaleCheckbox.disabled = true;
|
||
} else {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Tailscale not available</span>`;
|
||
tailscaleCheckbox.disabled = true;
|
||
}
|
||
} catch (e) {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check Tailscale status</span>`;
|
||
tailscaleCheckbox.disabled = true;
|
||
}
|
||
tailscaleCheckbox.checked = false;
|
||
|
||
updateServicePreview();
|
||
}
|
||
|
||
// Service Type Switching
|
||
function setupServiceTypeSwitching() {
|
||
const localRadio = document.getElementById('service-type-local');
|
||
const externalRadio = document.getElementById('service-type-external');
|
||
const localConfig = document.getElementById('local-service-config');
|
||
const externalConfig = document.getElementById('external-service-config');
|
||
const description = document.getElementById('service-type-description');
|
||
const localAdvanced = document.getElementById('local-advanced-options');
|
||
|
||
function switchServiceType() {
|
||
if (localRadio.checked) {
|
||
localConfig.style.display = 'grid';
|
||
externalConfig.style.display = 'none';
|
||
if (localAdvanced) localAdvanced.style.display = 'block';
|
||
description.textContent = 'Local: Service running on your network (Docker, VM, etc.)';
|
||
} else {
|
||
localConfig.style.display = 'none';
|
||
externalConfig.style.display = 'block';
|
||
if (localAdvanced) localAdvanced.style.display = 'none';
|
||
description.textContent = 'External: Service hosted elsewhere (seedhost.eu, cloud, etc.)';
|
||
}
|
||
}
|
||
|
||
localRadio?.addEventListener('change', switchServiceType);
|
||
externalRadio?.addEventListener('change', switchServiceType);
|
||
|
||
// Update external domain preview
|
||
document.getElementById('external-service-subdomain')?.addEventListener('input', (e) => {
|
||
const subdomain = e.target.value.trim() || 'subdomain';
|
||
document.getElementById('external-domain-preview').textContent = `${subdomain}.sami`;
|
||
});
|
||
}
|
||
|
||
// Create External Service
|
||
async function createExternalService() {
|
||
const name = document.getElementById('external-service-name').value.trim();
|
||
const subdomain = document.getElementById('external-service-subdomain').value.trim().toLowerCase();
|
||
const externalUrl = document.getElementById('external-service-url').value.trim();
|
||
const logo = document.getElementById('external-service-logo').value.trim();
|
||
const icon = document.getElementById('external-service-icon').value.trim();
|
||
const createDns = document.getElementById('external-create-dns').checked;
|
||
const createCaddy = document.getElementById('external-create-caddy').checked;
|
||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || '192.168.254.204';
|
||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||
|
||
// Validation
|
||
if (!name || !subdomain || !externalUrl) {
|
||
alert('Please fill in all required fields (Name, Subdomain, External URL)');
|
||
return;
|
||
}
|
||
|
||
if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) {
|
||
alert('External URL must start with http:// or https://');
|
||
return;
|
||
}
|
||
|
||
const domain = `${subdomain}.sami`;
|
||
|
||
try {
|
||
const results = {
|
||
dns: null,
|
||
caddy: null,
|
||
dashboard: false
|
||
};
|
||
|
||
// Step 1: Create DNS record if requested
|
||
if (createDns) {
|
||
const adminToken = getToken('dns2', 'admin');
|
||
if (adminToken) {
|
||
try {
|
||
// Don't pass token - let API use its auto-refreshed token
|
||
const dnsResponse = await fetch('/api/dns/record', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
domain: domain,
|
||
ip: proxyIp,
|
||
ttl: 300,
|
||
server: '100.74.102.61'
|
||
})
|
||
});
|
||
const dnsResult = await dnsResponse.json();
|
||
results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed');
|
||
} catch (e) {
|
||
results.dns = e.message;
|
||
}
|
||
} else {
|
||
results.dns = 'no admin token (configure in 🔑 Tokens)';
|
||
}
|
||
}
|
||
|
||
// Step 2: Create Caddy reverse proxy if requested
|
||
if (createCaddy) {
|
||
try {
|
||
const caddyConfig = {
|
||
subdomain: subdomain,
|
||
externalUrl: externalUrl,
|
||
preserveHost: preserveHost,
|
||
followRedirects: followRedirects,
|
||
sslType: 'caddy-managed',
|
||
caddyfilePath: 'C:\\caddy\\Caddyfile',
|
||
reloadCaddy: true
|
||
};
|
||
|
||
const caddyResponse = await fetch('/api/site/external', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(caddyConfig)
|
||
});
|
||
|
||
const caddyResult = await caddyResponse.json();
|
||
results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed');
|
||
} catch (e) {
|
||
results.caddy = e.message;
|
||
}
|
||
}
|
||
|
||
// Step 3: Add to dashboard
|
||
const newService = {
|
||
id: subdomain,
|
||
name: name,
|
||
url: `https://${domain}`,
|
||
externalUrl: externalUrl,
|
||
logo: logo || icon || '🌐',
|
||
isExternal: true,
|
||
isCustom: true
|
||
};
|
||
|
||
window.APPS.push(newService);
|
||
results.dashboard = true;
|
||
|
||
// Save to localStorage
|
||
const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr'];
|
||
const customServices = window.APPS.filter(app => !defaultServices.includes(app.id));
|
||
localStorage.setItem('custom-services', JSON.stringify(customServices));
|
||
|
||
// Save to services.json via API
|
||
try {
|
||
await fetch('/api/services', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(window.APPS)
|
||
});
|
||
} catch (e) {
|
||
console.warn('Failed to save to services.json:', e);
|
||
}
|
||
|
||
// Rebuild grid
|
||
buildGrid();
|
||
refreshAll();
|
||
|
||
// Close modal
|
||
closeAddServiceModal();
|
||
|
||
// Show results
|
||
let summary = `External service "${name}" added!\n\n`;
|
||
summary += `Dashboard: ${results.dashboard ? '✓ Added' : '✗ Failed'}\n`;
|
||
if (createDns) {
|
||
summary += `DNS Record: ${results.dns === 'created' ? '✓ Created' : '⚠ ' + results.dns}\n`;
|
||
}
|
||
if (createCaddy) {
|
||
summary += `Caddy Proxy: ${results.caddy === 'created' ? '✓ Created' : '⚠ ' + results.caddy}\n`;
|
||
}
|
||
summary += `\nAccess at: https://${domain}`;
|
||
|
||
alert(summary);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to create external service:', error);
|
||
alert(`Failed to create external service: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Close Add Service Modal
|
||
function closeAddServiceModal() {
|
||
document.getElementById('add-service-modal').classList.remove('show');
|
||
|
||
// Restore body scrolling
|
||
document.body.style.overflow = '';
|
||
|
||
// Clear form
|
||
document.getElementById('service-name-input').value = '';
|
||
document.getElementById('service-subdomain-input').value = '';
|
||
document.getElementById('service-port-input').value = '';
|
||
document.getElementById('service-ip-input').value = '192.168.1.100';
|
||
document.getElementById('service-logo-input').value = '';
|
||
document.getElementById('dns-ttl-input').value = '300';
|
||
document.getElementById('ssl-type-select').value = 'letsencrypt';
|
||
document.getElementById('ca-name-input').value = '';
|
||
document.getElementById('enable-auth').checked = false;
|
||
document.getElementById('enable-cors').checked = false;
|
||
document.getElementById('custom-headers-input').value = '';
|
||
document.getElementById('upstream-path-input').value = '/';
|
||
document.getElementById('health-check-input').value = '';
|
||
document.getElementById('timeout-input').value = '30';
|
||
}
|
||
|
||
// Create new service
|
||
async function createNewService() {
|
||
const name = document.getElementById('service-name-input').value.trim();
|
||
const subdomain = document.getElementById('service-subdomain-input').value.trim();
|
||
const port = document.getElementById('service-port-input').value.trim();
|
||
const ip = document.getElementById('service-ip-input').value.trim();
|
||
const logo = document.getElementById('service-logo-input').value.trim();
|
||
const createDns = document.getElementById('create-dns-record').checked;
|
||
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || 300;
|
||
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
|
||
|
||
// Get all config options for Caddy
|
||
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
|
||
const caName = document.getElementById('ca-name-input')?.value || '';
|
||
const existingCa = document.getElementById('existing-ca-select')?.value || '';
|
||
const enableAuth = document.getElementById('enable-auth')?.checked || false;
|
||
const enableCors = document.getElementById('enable-cors')?.checked || false;
|
||
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
|
||
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
|
||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||
|
||
// Get DNS credentials from Token Management (use DNS2 admin token for DNS record creation)
|
||
const dnsToken = getToken('dns2', 'admin');
|
||
|
||
// Validation
|
||
if (!name || !subdomain || !port || !ip) {
|
||
alert('Please fill in all required fields (Name, Subdomain, Port, IP)');
|
||
return;
|
||
}
|
||
|
||
if (createDns && !dnsToken) {
|
||
alert('DNS Admin token required. Configure it in the Tokens menu first.');
|
||
return;
|
||
}
|
||
|
||
const results = {
|
||
dns: null,
|
||
caddy: null,
|
||
dashboard: false
|
||
};
|
||
|
||
try {
|
||
// Step 1: Create DNS record if requested
|
||
if (createDns) {
|
||
console.log(`Creating DNS record: ${subdomain}.sami → ${ip}`);
|
||
try {
|
||
await createDnsRecord(subdomain, ip, ttl);
|
||
console.log('DNS record created successfully');
|
||
results.dns = 'created';
|
||
} catch (error) {
|
||
console.error('DNS creation failed:', error);
|
||
results.dns = error.message;
|
||
throw new Error(`DNS creation failed: ${error.message}`);
|
||
}
|
||
} else {
|
||
results.dns = 'skipped';
|
||
}
|
||
|
||
// Step 2: Generate and add Caddy configuration
|
||
console.log('Generating Caddy configuration...');
|
||
const caddyConfig = generateCaddyConfig({
|
||
subdomain,
|
||
port,
|
||
ip,
|
||
sslType,
|
||
caName,
|
||
existingCa,
|
||
enableAuth,
|
||
enableCors,
|
||
customHeaders,
|
||
upstreamPath,
|
||
healthCheck,
|
||
timeout,
|
||
tailscaleOnly
|
||
});
|
||
|
||
console.log('Adding Caddy configuration...');
|
||
try {
|
||
const caddyResponse = await fetch('/api/site', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
domain: `${subdomain}.sami`,
|
||
upstream: `${ip}:${port}`,
|
||
config: caddyConfig
|
||
})
|
||
});
|
||
|
||
const caddyResult = await caddyResponse.json();
|
||
if (caddyResult.success) {
|
||
console.log('Caddy configuration added and reloaded');
|
||
results.caddy = 'added & reloaded';
|
||
} else {
|
||
console.error('Caddy configuration failed:', caddyResult.error);
|
||
results.caddy = caddyResult.error || 'failed';
|
||
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Caddy API error:', error);
|
||
results.caddy = error.message;
|
||
throw new Error(`Caddy API error: ${error.message}`);
|
||
}
|
||
|
||
// Step 3: Add service to dashboard configuration
|
||
const serviceConfig = {
|
||
name,
|
||
subdomain,
|
||
port,
|
||
ip,
|
||
logo: logo || `/assets/${subdomain}.png`,
|
||
tailscaleOnly: tailscaleOnly || false
|
||
};
|
||
|
||
await addServiceToConfig(serviceConfig);
|
||
results.dashboard = true;
|
||
|
||
// Show detailed success message
|
||
let successMsg = `Service "${name}" created successfully!\n\n`;
|
||
successMsg += `URL: https://${subdomain}.sami${tailscaleOnly ? ' (Tailscale-only)' : ''}\n\n`;
|
||
successMsg += `Results:\n`;
|
||
successMsg += ` DNS Record: ${results.dns === 'created' ? '✓ Created' : results.dns === 'skipped' ? '○ Skipped' : '✗ ' + results.dns}\n`;
|
||
successMsg += ` Caddy Config: ${results.caddy === 'added & reloaded' ? '✓ Added & Reloaded' : '✗ ' + results.caddy}\n`;
|
||
successMsg += ` Dashboard: ${results.dashboard ? '✓ Added' : '✗ Failed'}`;
|
||
|
||
alert(successMsg);
|
||
|
||
closeAddServiceModal();
|
||
|
||
// Rebuild grid to show new service
|
||
buildGrid();
|
||
refreshAll();
|
||
|
||
} catch (error) {
|
||
console.error('Error creating service:', error);
|
||
|
||
let errorMsg = `Error creating service "${name}":\n\n${error.message}\n\n`;
|
||
errorMsg += `Results so far:\n`;
|
||
errorMsg += ` DNS Record: ${results.dns === 'created' ? '✓ Created' : results.dns === 'skipped' ? '○ Skipped' : results.dns ? '✗ ' + results.dns : '○ Not attempted'}\n`;
|
||
errorMsg += ` Caddy Config: ${results.caddy === 'added & reloaded' ? '✓ Added' : results.caddy ? '✗ ' + results.caddy : '○ Not attempted'}\n`;
|
||
errorMsg += ` Dashboard: ${results.dashboard ? '✓ Added' : '○ Not attempted'}`;
|
||
|
||
alert(errorMsg);
|
||
}
|
||
}
|
||
|
||
// Event Listeners for Add Service Modal
|
||
document.getElementById('add-service')?.addEventListener('click', openAddServiceModal);
|
||
document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal);
|
||
document.getElementById('add-service-create')?.addEventListener('click', () => {
|
||
const serviceType = document.querySelector('input[name="service-type"]:checked')?.value;
|
||
if (serviceType === 'external') {
|
||
createExternalService();
|
||
} else {
|
||
createNewService();
|
||
}
|
||
});
|
||
|
||
// Setup service type switching
|
||
setupServiceTypeSwitching();
|
||
|
||
// Initialize quick IP buttons on page load
|
||
initQuickIPButtons();
|
||
|
||
// Add Test Service - creates a local-only service card without DNS/Caddy setup
|
||
document.getElementById('add-service-test')?.addEventListener('click', async () => {
|
||
const name = document.getElementById('service-name-input').value.trim();
|
||
const subdomain = document.getElementById('service-subdomain-input').value.trim().toLowerCase();
|
||
const port = document.getElementById('service-port-input').value.trim() || '8080';
|
||
const ip = document.getElementById('service-ip-input').value.trim() || '192.168.1.100';
|
||
const logo = document.getElementById('service-logo-input').value.trim();
|
||
const reloadCaddy = document.getElementById('reload-caddy').checked;
|
||
|
||
// Minimal validation - only need name and subdomain for test
|
||
if (!name) {
|
||
alert('Please enter a Service Name');
|
||
return;
|
||
}
|
||
if (!subdomain) {
|
||
alert('Please enter a Subdomain');
|
||
return;
|
||
}
|
||
|
||
// Check for duplicate
|
||
if (APPS.find(app => app.id === subdomain)) {
|
||
alert(`A service with subdomain "${subdomain}" already exists`);
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('add-service-test');
|
||
const originalText = btn.textContent;
|
||
btn.textContent = 'Testing...';
|
||
btn.disabled = true;
|
||
|
||
let apiStatus = 'skipped';
|
||
let caddyStatus = 'skipped';
|
||
|
||
try {
|
||
// Test 1: Check if Caddy API server is running
|
||
try {
|
||
const healthResponse = await fetch('/api/health', {
|
||
method: 'GET',
|
||
signal: AbortSignal.timeout(3000)
|
||
});
|
||
if (healthResponse.ok) {
|
||
apiStatus = 'connected';
|
||
|
||
// Test 2: Try to reload Caddy (tests admin API connection)
|
||
if (reloadCaddy) {
|
||
try {
|
||
const reloadResponse = await fetch('/api/caddy/reload', {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(5000)
|
||
});
|
||
if (reloadResponse.ok) {
|
||
caddyStatus = 'reloaded';
|
||
} else {
|
||
const err = await reloadResponse.text();
|
||
caddyStatus = `failed: ${err}`;
|
||
}
|
||
} catch (e) {
|
||
caddyStatus = `error: ${e.message}`;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
apiStatus = 'not running';
|
||
}
|
||
|
||
// Add the test service card
|
||
const testService = {
|
||
id: subdomain,
|
||
name: name,
|
||
logo: logo || `/assets/${subdomain}.png`,
|
||
isTest: true
|
||
};
|
||
|
||
window.APPS.push(testService);
|
||
|
||
// Save to localStorage
|
||
const customServices = JSON.parse(localStorage.getItem('custom-services') || '[]');
|
||
customServices.push(testService);
|
||
localStorage.setItem('custom-services', JSON.stringify(customServices));
|
||
|
||
// Rebuild grid
|
||
buildGrid();
|
||
|
||
// Close modal
|
||
closeAddServiceModal();
|
||
|
||
// Show results
|
||
const results = [
|
||
`Test service "${name}" added!`,
|
||
'',
|
||
`API Server: ${apiStatus}`,
|
||
`Caddy Reload: ${caddyStatus}`,
|
||
'',
|
||
`Service URL: https://${subdomain}.sami`,
|
||
`Upstream: ${ip}:${port}`,
|
||
'',
|
||
apiStatus === 'not running'
|
||
? 'Start the Docker API server to enable full testing.'
|
||
: 'API connection successful!'
|
||
];
|
||
alert(results.join('\n'));
|
||
|
||
} catch (error) {
|
||
console.error('Test service error:', error);
|
||
alert(`Error: ${error.message}`);
|
||
} finally {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// SSL type change handler
|
||
document.getElementById('ssl-type-select')?.addEventListener('change', (e) => {
|
||
const existingCaConfig = document.getElementById('existing-ca-config');
|
||
const customCaConfig = document.getElementById('custom-ca-config');
|
||
|
||
// Hide all CA configs first
|
||
existingCaConfig.style.display = 'none';
|
||
customCaConfig.style.display = 'none';
|
||
|
||
// Show appropriate config
|
||
if (e.target.value === 'existing-ca') {
|
||
existingCaConfig.style.display = 'block';
|
||
} else if (e.target.value === 'custom-ca') {
|
||
customCaConfig.style.display = 'block';
|
||
}
|
||
|
||
updateServicePreview();
|
||
});
|
||
|
||
// Refresh CAs button
|
||
document.getElementById('refresh-cas')?.addEventListener('click', async () => {
|
||
const button = document.getElementById('refresh-cas');
|
||
const originalText = button.textContent;
|
||
button.textContent = '⏳ Loading...';
|
||
button.disabled = true;
|
||
|
||
try {
|
||
const caddyfilePath = document.getElementById('caddyfile-path-input').value || 'C:\\caddy\\Caddyfile';
|
||
await loadExistingCAs(caddyfilePath);
|
||
button.textContent = '✅ Refreshed';
|
||
} catch (error) {
|
||
button.textContent = '❌ Failed';
|
||
console.error('Failed to refresh CAs:', error);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
}, 2000);
|
||
});
|
||
|
||
// DNS record checkbox handler
|
||
document.getElementById('create-dns-record')?.addEventListener('change', (e) => {
|
||
const dnsConfig = document.getElementById('dns-config');
|
||
dnsConfig.style.display = e.target.checked ? 'block' : 'none';
|
||
});
|
||
|
||
// Add configuration to Caddyfile automatically
|
||
async function addToCaddyfile(config, caddyfilePath, reloadCaddy = true) {
|
||
try {
|
||
// Use localhost API server for Windows
|
||
const response = await fetch('/api/caddy/add-config', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
config: config,
|
||
caddyfilePath: caddyfilePath,
|
||
reloadCaddy: reloadCaddy,
|
||
subdomain: document.getElementById('service-subdomain-input').value.trim()
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Caddy API Error: ${response.status} - ${errorText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.status !== 'success') {
|
||
throw new Error(`Caddy Error: ${result.message || 'Unknown error'}`);
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
// If API endpoint doesn't exist, provide Windows-specific instructions
|
||
if (error.message.includes('fetch') || error.message.includes('Failed to fetch')) {
|
||
throw new Error('Caddy API server not running. Please start the API server first.');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Create backend API endpoint instructions
|
||
function showBackendInstructions() {
|
||
const instructions = `
|
||
SAMI Caddy API Setup for Windows 11:
|
||
|
||
STEP 1: Install the API Server
|
||
1. Navigate to your status/api folder
|
||
2. Double-click "install-and-run.bat"
|
||
3. This will install Node.js dependencies and start the server
|
||
|
||
STEP 2: Manual Setup (Alternative)
|
||
If the batch file doesn't work:
|
||
1. Open PowerShell as Administrator
|
||
2. cd to your status/api folder
|
||
3. Run: npm install
|
||
4. Run: npm start
|
||
|
||
STEP 3: Configure Caddyfile Path
|
||
Default path: C:\\caddy\\Caddyfile
|
||
Update the path in the modal if your Caddyfile is elsewhere.
|
||
|
||
STEP 4: Test the API
|
||
The API is proxied through Caddy at /api/*
|
||
Test by visiting: https://status.sami/api/health
|
||
|
||
REQUIREMENTS:
|
||
- Docker installed and running
|
||
- Caddy configured with API proxy routes
|
||
|
||
TROUBLESHOOTING:
|
||
- If API calls fail, ensure Docker container is running
|
||
- Check Caddy has the /api/* proxy routes configured
|
||
|
||
The API provides these endpoints (all proxied through /api/):
|
||
- DELETE /api/site/:domain (removes from Caddyfile)
|
||
- DELETE /api/dns/record (removes DNS record)
|
||
- GET /api/caddy/get-cas (lists CAs)
|
||
- POST /api/caddy/reload (reloads Caddy)
|
||
- GET /api/health (health check)
|
||
`;
|
||
|
||
alert(instructions);
|
||
}
|
||
|
||
// Radio button change handler for Caddy action
|
||
document.querySelectorAll('input[name="caddy-action"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
const caddyfileConfig = document.getElementById('caddyfile-config');
|
||
const copyButton = document.getElementById('copy-caddy-config');
|
||
const addButton = document.getElementById('add-to-caddyfile');
|
||
|
||
if (e.target.value === 'auto-add') {
|
||
caddyfileConfig.style.display = 'block';
|
||
addButton.style.display = 'inline-block';
|
||
copyButton.textContent = '📋 Copy Config';
|
||
} else {
|
||
caddyfileConfig.style.display = 'none';
|
||
addButton.style.display = 'none';
|
||
copyButton.textContent = '📋 Copy Config';
|
||
}
|
||
});
|
||
});
|
||
|
||
// Add to Caddyfile button
|
||
document.getElementById('add-to-caddyfile')?.addEventListener('click', async () => {
|
||
const config = document.getElementById('caddy-config-preview').value;
|
||
const caddyfilePath = document.getElementById('caddyfile-path-input').value.trim();
|
||
const reloadCaddy = document.getElementById('reload-caddy').checked;
|
||
|
||
if (!caddyfilePath) {
|
||
alert('Please specify the Caddyfile path');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const button = document.getElementById('add-to-caddyfile');
|
||
const originalText = button.textContent;
|
||
button.textContent = '⏳ Adding...';
|
||
button.disabled = true;
|
||
|
||
await addToCaddyfile(config, caddyfilePath, reloadCaddy);
|
||
|
||
button.textContent = '✅ Added!';
|
||
setTimeout(() => {
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
}, 3000);
|
||
|
||
alert('Configuration added to Caddyfile successfully!' + (reloadCaddy ? '\\nCaddy has been reloaded.' : ''));
|
||
|
||
} catch (error) {
|
||
console.error('Error adding to Caddyfile:', error);
|
||
|
||
const button = document.getElementById('add-to-caddyfile');
|
||
button.textContent = '❌ Failed';
|
||
button.disabled = false;
|
||
|
||
setTimeout(() => {
|
||
button.textContent = '🔧 Add to Caddyfile';
|
||
}, 3000);
|
||
|
||
if (error.message.includes('Backend API not available')) {
|
||
const showInstructions = confirm('Backend API endpoint not found. Would you like to see implementation instructions?');
|
||
if (showInstructions) {
|
||
showBackendInstructions();
|
||
}
|
||
} else {
|
||
alert(`Error: ${error.message}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Real-time preview updates
|
||
['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input',
|
||
'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input',
|
||
'health-check-input', 'timeout-input'].forEach(id => {
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
element.addEventListener('input', updateServicePreview);
|
||
element.addEventListener('change', updateServicePreview);
|
||
}
|
||
});
|
||
|
||
// Load custom services from localStorage on startup
|
||
function loadCustomServices() {
|
||
const customServices = localStorage.getItem('custom-services');
|
||
if (customServices) {
|
||
try {
|
||
const services = JSON.parse(customServices);
|
||
// Merge with default APPS, avoiding duplicates
|
||
services.forEach(service => {
|
||
if (!window.APPS.find(app => app.id === service.id)) {
|
||
window.APPS.push(service);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn('Failed to load custom services:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize custom services
|
||
loadCustomServices();
|
||
|
||
// Staggered animation for top cards too
|
||
function animateTopCards() {
|
||
const topCards = document.querySelectorAll('.top .card');
|
||
topCards.forEach((card, index) => {
|
||
card.style.opacity = '0';
|
||
card.style.transform = 'translateY(20px)';
|
||
setTimeout(() => {
|
||
card.style.transition = 'opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||
card.style.opacity = '1';
|
||
card.style.transform = 'translateY(0)';
|
||
}, index * 150); // 150ms delay between top cards
|
||
});
|
||
}
|
||
|
||
// Initialize: load services from API then build grid
|
||
(async () => {
|
||
await loadServices();
|
||
buildGrid();
|
||
animateTopCards();
|
||
refreshAll();
|
||
setInterval(refreshAll, 10000);
|
||
})();
|
||
|
||
// Test weather elements immediately
|
||
setTimeout(() => {
|
||
console.log('Testing weather elements after page load...');
|
||
const testElements = {
|
||
icon: document.querySelector('.weather-icon'),
|
||
temp: document.querySelector('.weather-temp'),
|
||
condition: document.querySelector('.weather-condition'),
|
||
location: document.querySelector('.weather-location'),
|
||
wind: document.querySelector('.weather-wind')
|
||
};
|
||
console.log('Weather elements test:', testElements);
|
||
|
||
// Try to set a test value
|
||
if (testElements.location) {
|
||
testElements.location.textContent = 'TEST LOCATION';
|
||
console.log('Set test location, current value:', testElements.location.textContent);
|
||
}
|
||
}, 1000);
|
||
|
||
/* Weather Widget */
|
||
const WEATHER_KEY = 'weather-zip';
|
||
|
||
// Wait for DOM to be ready and get weather elements
|
||
function getWeatherElements() {
|
||
const elements = {
|
||
icon: document.querySelector('.weather-icon'),
|
||
temp: document.querySelector('.weather-temp'),
|
||
condition: document.querySelector('.weather-condition'),
|
||
location: document.querySelector('.weather-location'),
|
||
wind: document.querySelector('.weather-wind')
|
||
};
|
||
|
||
console.log('Weather elements found:', {
|
||
icon: !!elements.icon,
|
||
temp: !!elements.temp,
|
||
condition: !!elements.condition,
|
||
location: !!elements.location,
|
||
wind: !!elements.wind
|
||
});
|
||
|
||
return elements;
|
||
}
|
||
|
||
// Weather icon mapping
|
||
const weatherIcons = {
|
||
'clear sky': '☀️',
|
||
'few clouds': '🌤️',
|
||
'scattered clouds': '⛅',
|
||
'broken clouds': '☁️',
|
||
'overcast clouds': '☁️',
|
||
'shower rain': '🌦️',
|
||
'rain': '🌧️',
|
||
'thunderstorm': '⛈️',
|
||
'snow': '❄️',
|
||
'mist': '🌫️',
|
||
'fog': '🌫️',
|
||
'haze': '🌫️'
|
||
};
|
||
|
||
function getWeatherIcon(description) {
|
||
const desc = description.toLowerCase();
|
||
for (const [key, icon] of Object.entries(weatherIcons)) {
|
||
if (desc.includes(key)) return icon;
|
||
}
|
||
return '🌤️'; // default
|
||
}
|
||
|
||
async function fetchWeather(zipCode) {
|
||
try {
|
||
// Use wttr.in for comprehensive weather data
|
||
const response = await fetch(`https://wttr.in/${zipCode}?format=j1`);
|
||
if (!response.ok) throw new Error('Weather fetch failed');
|
||
|
||
const data = await response.json();
|
||
const current = data.current_condition[0];
|
||
const area = data.nearest_area[0];
|
||
|
||
return {
|
||
temp: Math.round(current.temp_F),
|
||
condition: current.weatherDesc[0].value,
|
||
description: current.weatherDesc[0].value.toLowerCase(),
|
||
city: area.areaName[0].value,
|
||
state: area.region[0].value,
|
||
windSpeed: Math.round(current.windspeedMiles || 0),
|
||
windDir: current.winddir16Point || 'N'
|
||
};
|
||
} catch (error) {
|
||
console.warn('Weather fetch failed:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function updateWeather() {
|
||
const weatherWidget = getWeatherElements();
|
||
|
||
// Check if elements exist
|
||
if (!weatherWidget.icon || !weatherWidget.temp || !weatherWidget.condition || !weatherWidget.location || !weatherWidget.wind) {
|
||
console.warn('Weather widget elements not found');
|
||
return;
|
||
}
|
||
|
||
const zipCode = localStorage.getItem(WEATHER_KEY);
|
||
console.log('Weather ZIP code:', zipCode); // Debug log
|
||
|
||
if (!zipCode) {
|
||
weatherWidget.location.textContent = 'Set ZIP Code';
|
||
weatherWidget.temp.textContent = '--°';
|
||
weatherWidget.condition.textContent = 'Click ⚙️ to configure';
|
||
weatherWidget.wind.textContent = '--';
|
||
weatherWidget.icon.textContent = '🌤️';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('Fetching weather for:', zipCode); // Debug log
|
||
const weather = await fetchWeather(zipCode);
|
||
console.log('Weather data:', weather); // Debug log
|
||
|
||
if (weather) {
|
||
console.log('Updating weather display with:', weather);
|
||
weatherWidget.location.textContent = `${weather.city}, ${weather.state}`;
|
||
weatherWidget.temp.textContent = `${weather.temp}°F`;
|
||
weatherWidget.condition.textContent = weather.condition;
|
||
weatherWidget.wind.textContent = `Wind: ${weather.windSpeed} mph ${weather.windDir}`;
|
||
weatherWidget.icon.textContent = getWeatherIcon(weather.description);
|
||
|
||
console.log('Weather display updated. Current values:', {
|
||
location: weatherWidget.location.textContent,
|
||
temp: weatherWidget.temp.textContent,
|
||
condition: weatherWidget.condition.textContent,
|
||
wind: weatherWidget.wind.textContent
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Weather update error:', error); // Debug log
|
||
weatherWidget.location.textContent = 'Weather Error';
|
||
weatherWidget.temp.textContent = 'Error';
|
||
weatherWidget.condition.textContent = 'Failed to load';
|
||
weatherWidget.wind.textContent = '--';
|
||
}
|
||
}
|
||
|
||
// Weather settings modal
|
||
const modal = document.getElementById('weather-modal');
|
||
const zipInput = document.getElementById('zip-input');
|
||
|
||
document.getElementById('weather-settings')?.addEventListener('click', () => {
|
||
zipInput.value = localStorage.getItem(WEATHER_KEY) || '';
|
||
modal.classList.add('show');
|
||
zipInput.focus();
|
||
});
|
||
|
||
document.getElementById('weather-cancel')?.addEventListener('click', () => {
|
||
modal.classList.remove('show');
|
||
});
|
||
|
||
document.getElementById('weather-save')?.addEventListener('click', () => {
|
||
const zip = zipInput.value.trim();
|
||
if (zip && /^\d{5}(-\d{4})?$/.test(zip)) {
|
||
localStorage.setItem(WEATHER_KEY, zip);
|
||
modal.classList.remove('show');
|
||
updateWeather();
|
||
} else {
|
||
alert('Please enter a valid ZIP code (e.g., 90210 or 90210-1234)');
|
||
}
|
||
});
|
||
|
||
// Close modal on outside click
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
|
||
// Close modal on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||
modal.classList.remove('show');
|
||
}
|
||
});
|
||
|
||
// Initialize weather
|
||
console.log('Initializing weather widget...');
|
||
updateWeather();
|
||
// Update weather every 10 minutes
|
||
setInterval(updateWeather, 600000);
|
||
})();
|
||
|
||
// Setup Wizard System - Server-side config storage
|
||
(function () {
|
||
let currentConfigType = 'homelab';
|
||
let serverConfig = null;
|
||
|
||
// Check server for existing config on page load
|
||
async function checkServerConfig() {
|
||
try {
|
||
const response = await fetch('/api/config');
|
||
if (response.ok) {
|
||
serverConfig = await response.json();
|
||
if (serverConfig && serverConfig.setupComplete) {
|
||
// Config exists on server - don't show wizard
|
||
console.log('DashCaddy config loaded from server:', serverConfig.configurationType);
|
||
document.getElementById('setup-wizard').style.display = 'none';
|
||
return true;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('Could not fetch server config, checking localStorage fallback:', error.message);
|
||
}
|
||
|
||
// Fallback: check localStorage for backwards compatibility
|
||
const localSetup = localStorage.getItem('dashcaddy-setup');
|
||
if (localSetup) {
|
||
console.log('Using localStorage config (legacy)');
|
||
document.getElementById('setup-wizard').style.display = 'none';
|
||
return true;
|
||
}
|
||
|
||
// No config found - show wizard
|
||
document.getElementById('setup-wizard').style.display = 'flex';
|
||
return false;
|
||
}
|
||
|
||
// Initialize on page load
|
||
checkServerConfig();
|
||
|
||
// Step navigation
|
||
function showStep(stepId) {
|
||
document.querySelectorAll('.setup-step').forEach(step => {
|
||
step.style.display = 'none';
|
||
});
|
||
const targetStep = document.getElementById(stepId);
|
||
if (targetStep) {
|
||
targetStep.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Show summary
|
||
function showSummary() {
|
||
const summaryContent = document.getElementById('setup-summary-content');
|
||
if (!summaryContent) return;
|
||
|
||
let html = '<div style="display: grid; gap: 20px;">';
|
||
|
||
if (currentConfigType === 'homelab') {
|
||
const tld = document.getElementById('setup-tld')?.value?.trim() || '.sami';
|
||
const caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||
const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || '';
|
||
const dnsPort = document.getElementById('setup-dns-port')?.value?.trim() || '5380';
|
||
|
||
html += `
|
||
<div>
|
||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||
<div><strong>TLD:</strong> ${tld}</div>
|
||
<div><strong>Certificate Authority:</strong> ${caName}</div>
|
||
<div><strong>DNS Server:</strong> ${dnsIP}:${dnsPort}</div>
|
||
<div><strong>Example URLs:</strong> https://uptime${tld}, https://nextcloud${tld}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (currentConfigType === 'simple') {
|
||
const ip = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost';
|
||
|
||
html += `
|
||
<div>
|
||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||
<div><strong>Access Method:</strong> IP:Port only</div>
|
||
<div><strong>Default IP:</strong> ${ip}</div>
|
||
<div><strong>SSL:</strong> None (HTTP only)</div>
|
||
<div><strong>Example URLs:</strong> http://${ip}:8080, http://${ip}:3000</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (currentConfigType === 'public') {
|
||
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||
|
||
html += `
|
||
<div>
|
||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||
<div><strong>Domain:</strong> ${domain}</div>
|
||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||
<div><strong>Email:</strong> ${email}</div>
|
||
<div><strong>Example URLs:</strong> https://app.${domain}, https://cloud.${domain}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '</div>';
|
||
summaryContent.innerHTML = html;
|
||
showStep('setup-step-summary');
|
||
}
|
||
|
||
// Save config to server
|
||
async function saveConfigToServer(config) {
|
||
try {
|
||
const response = await fetch('/api/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
console.log('Config saved to server:', result);
|
||
return true;
|
||
} else {
|
||
console.error('Failed to save config to server:', response.status);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving config to server:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Finish setup handler
|
||
async function finishSetup() {
|
||
const config = {
|
||
setupComplete: true,
|
||
configurationType: currentConfigType,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
if (currentConfigType === 'homelab') {
|
||
config.tld = document.getElementById('setup-tld')?.value?.trim() || '.sami';
|
||
config.caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||
config.dns = {
|
||
provider: 'technitium',
|
||
ip: document.getElementById('setup-dns-ip')?.value?.trim() || '',
|
||
port: document.getElementById('setup-dns-port')?.value?.trim() || '5380',
|
||
token: document.getElementById('setup-dns-token')?.value?.trim() || ''
|
||
};
|
||
config.defaults = {
|
||
dnsType: 'private',
|
||
sslType: 'internal',
|
||
targetIP: 'localhost'
|
||
};
|
||
} else if (currentConfigType === 'simple') {
|
||
config.defaultIP = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost';
|
||
config.defaults = {
|
||
dnsType: 'none',
|
||
sslType: 'none',
|
||
targetIP: config.defaultIP
|
||
};
|
||
} else if (currentConfigType === 'public') {
|
||
config.domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||
config.email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||
config.defaults = {
|
||
dnsType: 'public',
|
||
sslType: 'letsencrypt',
|
||
targetIP: 'localhost'
|
||
};
|
||
}
|
||
|
||
// Save to server (primary) and localStorage (fallback)
|
||
const savedToServer = await saveConfigToServer(config);
|
||
localStorage.setItem('dashcaddy-config', JSON.stringify(config));
|
||
localStorage.setItem('dashcaddy-setup', 'completed');
|
||
|
||
// Hide wizard
|
||
document.getElementById('setup-wizard').style.display = 'none';
|
||
|
||
// Show success notification
|
||
const configName = currentConfigType === 'homelab' ? 'Professional Home Lab' :
|
||
currentConfigType === 'simple' ? 'Simple Setup' : 'Public Server';
|
||
const saveLocation = savedToServer ? 'server (shared across all devices)' : 'locally (this browser only)';
|
||
|
||
alert(`Setup Complete!\n\nYour DashCaddy is configured for: ${configName}\n\nSettings saved to: ${saveLocation}`);
|
||
|
||
// Reload page to apply configuration
|
||
setTimeout(() => location.reload(), 500);
|
||
}
|
||
|
||
// ===== Event Handlers using direct onclick for reliability =====
|
||
|
||
// Step 1: Continue button
|
||
const step1Next = document.getElementById('setup-step-1-next');
|
||
if (step1Next) {
|
||
step1Next.onclick = function(e) {
|
||
e.preventDefault();
|
||
const selected = document.querySelector('input[name="config-type"]:checked');
|
||
if (selected) {
|
||
currentConfigType = selected.value;
|
||
}
|
||
if (currentConfigType === 'homelab') {
|
||
showStep('setup-step-homelab');
|
||
} else if (currentConfigType === 'simple') {
|
||
showStep('setup-step-simple');
|
||
} else if (currentConfigType === 'public') {
|
||
showStep('setup-step-public');
|
||
} else {
|
||
showStep('setup-step-homelab');
|
||
}
|
||
};
|
||
}
|
||
|
||
// Skip setup button
|
||
const skipBtn = document.getElementById('setup-skip');
|
||
if (skipBtn) {
|
||
skipBtn.onclick = async function(e) {
|
||
e.preventDefault();
|
||
if (confirm('Skip setup? You can run it later from Settings.')) {
|
||
// Save skip status to server
|
||
await saveConfigToServer({ setupComplete: true, skipped: true, timestamp: new Date().toISOString() });
|
||
localStorage.setItem('dashcaddy-setup', 'skipped');
|
||
document.getElementById('setup-wizard').style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
// Home Lab: TLD Preview
|
||
const tldInput = document.getElementById('setup-tld');
|
||
if (tldInput) {
|
||
tldInput.oninput = function(e) {
|
||
const tld = e.target.value || '.sami';
|
||
const preview1 = document.getElementById('tld-preview');
|
||
const preview2 = document.getElementById('tld-preview-2');
|
||
if (preview1) preview1.textContent = tld;
|
||
if (preview2) preview2.textContent = tld;
|
||
};
|
||
}
|
||
|
||
// Home Lab navigation
|
||
const homelabBack = document.getElementById('setup-homelab-back');
|
||
if (homelabBack) {
|
||
homelabBack.onclick = function(e) {
|
||
e.preventDefault();
|
||
showStep('setup-step-1');
|
||
};
|
||
}
|
||
|
||
const homelabNext = document.getElementById('setup-homelab-next');
|
||
if (homelabNext) {
|
||
homelabNext.onclick = function(e) {
|
||
e.preventDefault();
|
||
const tld = document.getElementById('setup-tld')?.value?.trim() || '';
|
||
const caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||
const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || '';
|
||
|
||
if (!tld || !tld.startsWith('.')) {
|
||
alert('Please enter a valid TLD starting with a dot (e.g., .sami)');
|
||
return;
|
||
}
|
||
if (!caName) {
|
||
alert('Please enter a Certificate Authority name');
|
||
return;
|
||
}
|
||
if (!dnsIP) {
|
||
alert('Please enter your DNS server IP address');
|
||
return;
|
||
}
|
||
showSummary();
|
||
};
|
||
}
|
||
|
||
// Simple navigation
|
||
const simpleBack = document.getElementById('setup-simple-back');
|
||
if (simpleBack) {
|
||
simpleBack.onclick = function(e) {
|
||
e.preventDefault();
|
||
showStep('setup-step-1');
|
||
};
|
||
}
|
||
|
||
const simpleNext = document.getElementById('setup-simple-next');
|
||
if (simpleNext) {
|
||
simpleNext.onclick = function(e) {
|
||
e.preventDefault();
|
||
showSummary();
|
||
};
|
||
}
|
||
|
||
// Public navigation
|
||
const publicBack = document.getElementById('setup-public-back');
|
||
if (publicBack) {
|
||
publicBack.onclick = function(e) {
|
||
e.preventDefault();
|
||
showStep('setup-step-1');
|
||
};
|
||
}
|
||
|
||
const publicNext = document.getElementById('setup-public-next');
|
||
if (publicNext) {
|
||
publicNext.onclick = function(e) {
|
||
e.preventDefault();
|
||
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||
|
||
if (!domain) {
|
||
alert('Please enter your domain name');
|
||
return;
|
||
}
|
||
if (!email || !email.includes('@')) {
|
||
alert('Please enter a valid email address');
|
||
return;
|
||
}
|
||
showSummary();
|
||
};
|
||
}
|
||
|
||
// Summary navigation
|
||
const summaryBack = document.getElementById('setup-summary-back');
|
||
if (summaryBack) {
|
||
summaryBack.onclick = function(e) {
|
||
e.preventDefault();
|
||
if (currentConfigType === 'homelab') {
|
||
showStep('setup-step-homelab');
|
||
} else if (currentConfigType === 'simple') {
|
||
showStep('setup-step-simple');
|
||
} else if (currentConfigType === 'public') {
|
||
showStep('setup-step-public');
|
||
}
|
||
};
|
||
}
|
||
|
||
// Finish setup button
|
||
const finishBtn = document.getElementById('setup-finish');
|
||
if (finishBtn) {
|
||
finishBtn.onclick = function(e) {
|
||
e.preventDefault();
|
||
finishSetup();
|
||
};
|
||
}
|
||
|
||
// Expose function to get global config (from server or localStorage)
|
||
window.getGlobalConfig = async function() {
|
||
// Try server first
|
||
try {
|
||
const response = await fetch('/api/config');
|
||
if (response.ok) {
|
||
const config = await response.json();
|
||
if (config && config.setupComplete) {
|
||
return config;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not fetch config from server');
|
||
}
|
||
|
||
// Fallback to localStorage
|
||
const configStr = localStorage.getItem('dashcaddy-config');
|
||
if (configStr) {
|
||
return JSON.parse(configStr);
|
||
}
|
||
|
||
// Return default config if not set
|
||
return {
|
||
setupComplete: false,
|
||
configurationType: 'homelab',
|
||
tld: '.sami',
|
||
caName: 'Sami Home Network Root CA',
|
||
defaults: {
|
||
dnsType: 'private',
|
||
sslType: 'internal',
|
||
targetIP: 'localhost'
|
||
}
|
||
};
|
||
};
|
||
|
||
// Expose reset function for settings
|
||
window.resetSetupWizard = async function() {
|
||
if (confirm('Reset DashCaddy configuration? This will show the setup wizard again.')) {
|
||
try {
|
||
await fetch('/api/config', { method: 'DELETE' });
|
||
} catch (e) {
|
||
console.warn('Could not delete server config');
|
||
}
|
||
localStorage.removeItem('dashcaddy-setup');
|
||
localStorage.removeItem('dashcaddy-config');
|
||
location.reload();
|
||
}
|
||
};
|
||
})();
|
||
|
||
// App Selector System
|
||
(function () {
|
||
const APPS_KEY = 'custom-apps';
|
||
|
||
// Cache for API templates
|
||
let apiTemplates = null;
|
||
let apiCategories = null;
|
||
|
||
const modal = document.getElementById('app-selector-modal');
|
||
const grid = document.getElementById('app-selector-grid');
|
||
|
||
// Fetch app templates from API
|
||
async function fetchAppTemplates() {
|
||
try {
|
||
const response = await fetch('/api/apps/templates');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
apiTemplates = data.templates;
|
||
apiCategories = data.categories;
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch app templates:', e);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Check port availability
|
||
async function checkPortAvailability(port) {
|
||
try {
|
||
const response = await fetch(`/api/apps/check-port/${port}`);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (e) {
|
||
console.error('Failed to check port:', e);
|
||
return { available: true }; // Assume available on error
|
||
}
|
||
}
|
||
|
||
// Get suggested available port
|
||
async function getSuggestedPort(basePort) {
|
||
try {
|
||
const response = await fetch(`/api/apps/suggest-port/${basePort}`);
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
return data.suggestedPort;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to get suggested port:', e);
|
||
}
|
||
return basePort;
|
||
}
|
||
|
||
// Build app selector grid from API templates
|
||
async function buildAppSelector() {
|
||
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>';
|
||
|
||
// Fetch templates if not cached
|
||
if (!apiTemplates) {
|
||
const success = await fetchAppTemplates();
|
||
if (!success) {
|
||
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';
|
||
return;
|
||
}
|
||
}
|
||
|
||
grid.innerHTML = '';
|
||
|
||
// Group templates by category
|
||
const byCategory = {};
|
||
for (const [appId, template] of Object.entries(apiTemplates)) {
|
||
const category = template.category || 'Other';
|
||
if (!byCategory[category]) {
|
||
byCategory[category] = [];
|
||
}
|
||
byCategory[category].push({ id: appId, ...template });
|
||
}
|
||
|
||
// Sort categories by the order in apiCategories if available
|
||
const categoryOrder = apiCategories ? Object.keys(apiCategories) : Object.keys(byCategory).sort();
|
||
|
||
for (const category of categoryOrder) {
|
||
const apps = byCategory[category];
|
||
if (!apps || apps.length === 0) continue;
|
||
|
||
// Sort apps by popularity (descending)
|
||
apps.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
||
|
||
// Category header with icon and color from API
|
||
const header = document.createElement('div');
|
||
header.className = 'app-category-header';
|
||
const categoryInfo = apiCategories?.[category] || {};
|
||
header.innerHTML = `${categoryInfo.icon || ''} ${category}`;
|
||
if (categoryInfo.color) {
|
||
header.style.borderBottomColor = categoryInfo.color;
|
||
}
|
||
grid.appendChild(header);
|
||
|
||
// App options
|
||
apps.forEach(app => {
|
||
const option = document.createElement('div');
|
||
option.className = 'app-option';
|
||
|
||
// Show difficulty badge
|
||
const difficultyBadge = app.difficulty ?
|
||
`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${
|
||
app.difficulty === 'Easy' ? '#2ecc71' :
|
||
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
|
||
}20; color: ${
|
||
app.difficulty === 'Easy' ? '#2ecc71' :
|
||
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
|
||
};">${app.difficulty}</div>` : '';
|
||
|
||
option.innerHTML = `
|
||
<div class="app-option-icon">${app.icon || '📦'}</div>
|
||
<div class="app-option-name">${app.name}</div>
|
||
<div class="app-option-desc">${app.description || ''}</div>
|
||
${difficultyBadge}
|
||
`;
|
||
option.onclick = () => showDeployConfig(app);
|
||
grid.appendChild(option);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Show deployment configuration modal
|
||
async function showDeployConfig(appTemplate) {
|
||
const deployModal = document.getElementById('app-deploy-modal');
|
||
const title = document.getElementById('app-deploy-title');
|
||
const subdomainInput = document.getElementById('deploy-subdomain');
|
||
const urlPreview = document.getElementById('deploy-url-preview');
|
||
const ipInput = document.getElementById('deploy-ip');
|
||
const portInput = document.getElementById('deploy-port');
|
||
const tailscaleCheckbox = document.getElementById('deploy-tailscale-only');
|
||
const tailscaleStatus = document.getElementById('tailscale-status');
|
||
|
||
// Set title with app info
|
||
title.textContent = `Deploy ${appTemplate.name}`;
|
||
|
||
// Pre-fill subdomain from template or app ID
|
||
const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, '');
|
||
subdomainInput.value = defaultSubdomain;
|
||
|
||
// Reset to defaults
|
||
document.querySelector('input[name="dns-type"][value="private"]').checked = true;
|
||
document.querySelector('input[name="ssl-type"][value="internal"]').checked = true;
|
||
ipInput.value = 'localhost';
|
||
tailscaleCheckbox.checked = false;
|
||
|
||
// Set default port from template and check availability
|
||
const defaultPort = appTemplate.defaultPort || 8080;
|
||
portInput.value = '';
|
||
portInput.placeholder = `Default: ${defaultPort}`;
|
||
|
||
// Add port status element if not exists
|
||
let portStatus = document.getElementById('deploy-port-status');
|
||
if (!portStatus) {
|
||
portStatus = document.createElement('div');
|
||
portStatus.id = 'deploy-port-status';
|
||
portStatus.style.cssText = 'font-size: 0.8rem; margin-top: 4px;';
|
||
portInput.parentNode.appendChild(portStatus);
|
||
}
|
||
|
||
// Check default port availability
|
||
async function checkAndUpdatePortStatus() {
|
||
const portToCheck = portInput.value || defaultPort;
|
||
portStatus.innerHTML = '<span style="color: var(--muted);">Checking port...</span>';
|
||
|
||
const result = await checkPortAvailability(portToCheck);
|
||
if (result.available) {
|
||
portStatus.innerHTML = `<span style="color: #4caf50;">Port ${portToCheck} is available</span>`;
|
||
} else {
|
||
const suggestedPort = await getSuggestedPort(defaultPort);
|
||
portStatus.innerHTML = `
|
||
<span style="color: #e74c3c;">Port ${portToCheck} in use by ${result.conflict?.usedBy || 'unknown'}</span>
|
||
<button type="button" onclick="document.getElementById('deploy-port').value='${suggestedPort}'; this.parentNode.innerHTML='<span style=\\'color: #4caf50;\\'>Using suggested port ${suggestedPort}</span>';"
|
||
style="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;">
|
||
Use ${suggestedPort}
|
||
</button>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Check port on input change (debounced)
|
||
let portCheckTimeout;
|
||
portInput.oninput = function() {
|
||
clearTimeout(portCheckTimeout);
|
||
portCheckTimeout = setTimeout(checkAndUpdatePortStatus, 500);
|
||
};
|
||
|
||
// Initial port check
|
||
checkAndUpdatePortStatus();
|
||
|
||
// Fetch Tailscale status
|
||
try {
|
||
const response = await fetch('/api/tailscale/status');
|
||
const data = await response.json();
|
||
if (data.success && data.installed && data.connected) {
|
||
tailscaleStatus.innerHTML = `
|
||
<span style="color: #4caf50;">Connected</span>
|
||
<span style="color: var(--muted); margin-left: 8px;">${data.self?.hostname} (${data.self?.ip})</span>
|
||
<span style="color: var(--muted); margin-left: 8px;">| ${data.deviceCount} devices</span>
|
||
`;
|
||
} else if (data.installed) {
|
||
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">Not connected</span>`;
|
||
} else {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
|
||
tailscaleCheckbox.disabled = true;
|
||
}
|
||
} catch (e) {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check status</span>`;
|
||
}
|
||
|
||
// Update URL preview in real-time
|
||
function updateUrlPreview() {
|
||
const subdomain = subdomainInput.value || 'subdomain';
|
||
const dnsType = document.querySelector('input[name="dns-type"]:checked').value;
|
||
const sslType = document.querySelector('input[name="ssl-type"]:checked').value;
|
||
|
||
let url = '';
|
||
if (dnsType === 'private') {
|
||
const protocol = sslType === 'none' ? 'http' : 'https';
|
||
url = `${protocol}://${subdomain}.sami`;
|
||
} else if (dnsType === 'public') {
|
||
const protocol = sslType === 'none' ? 'http' : 'https';
|
||
url = `${protocol}://${subdomain}`;
|
||
} else {
|
||
const port = portInput.value || appTemplate.defaultPort || '8080';
|
||
url = `http://${ipInput.value}:${port}`;
|
||
}
|
||
urlPreview.textContent = url;
|
||
}
|
||
|
||
// Attach listeners
|
||
subdomainInput.oninput = updateUrlPreview;
|
||
ipInput.oninput = updateUrlPreview;
|
||
portInput.oninput = updateUrlPreview;
|
||
document.querySelectorAll('input[name="dns-type"]').forEach(radio => {
|
||
radio.onchange = updateUrlPreview;
|
||
});
|
||
document.querySelectorAll('input[name="ssl-type"]').forEach(radio => {
|
||
radio.onchange = updateUrlPreview;
|
||
});
|
||
|
||
updateUrlPreview();
|
||
|
||
// Close app selector, open deploy config
|
||
modal.classList.remove('show');
|
||
deployModal.classList.add('show');
|
||
|
||
// Store app template for deployment
|
||
deployModal.dataset.appTemplate = JSON.stringify(appTemplate);
|
||
}
|
||
|
||
// Add app to grid with full Docker deployment
|
||
async function addAppToGrid(deployConfig) {
|
||
const appTemplate = deployConfig.appTemplate;
|
||
const customApps = JSON.parse(localStorage.getItem(APPS_KEY) || '[]');
|
||
|
||
// Check if app already exists - allow retry if deployment failed
|
||
const existingApp = customApps.find(a => a.id === deployConfig.subdomain);
|
||
if (existingApp) {
|
||
const confirmed = confirm(`An app with subdomain "${deployConfig.subdomain}" already exists. Redeploy?`);
|
||
if (!confirmed) return;
|
||
// Remove from localStorage to allow redeployment
|
||
const index = customApps.indexOf(existingApp);
|
||
customApps.splice(index, 1);
|
||
localStorage.setItem(APPS_KEY, JSON.stringify(customApps));
|
||
}
|
||
|
||
// Check port availability before deployment
|
||
const portToUse = deployConfig.port || appTemplate.defaultPort || 8080;
|
||
showNotification(`Checking port ${portToUse} availability...`, 'info', 0);
|
||
|
||
const portCheck = await checkPortAvailability(portToUse);
|
||
if (!portCheck.available) {
|
||
const suggestedPort = await getSuggestedPort(appTemplate.defaultPort || 8080);
|
||
const useAlternate = confirm(
|
||
`Port ${portToUse} is already in use by ${portCheck.conflict?.usedBy || 'another container'}.\n\n` +
|
||
`Would you like to use port ${suggestedPort} instead?`
|
||
);
|
||
if (useAlternate) {
|
||
deployConfig.port = suggestedPort;
|
||
} else {
|
||
showNotification('Deployment cancelled - port conflict', 'error', 5000);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Show deployment progress
|
||
showNotification(`Deploying ${appTemplate.name}...`, 'info', 0);
|
||
|
||
try {
|
||
// Prepare deployment config from user's choices
|
||
const apiDeployConfig = {
|
||
appId: appTemplate.id,
|
||
config: {
|
||
subdomain: deployConfig.subdomain,
|
||
ip: deployConfig.ip,
|
||
createDns: deployConfig.dnsType === 'private', // Only create DNS for private
|
||
port: deployConfig.port || appTemplate.defaultPort || null, // Use custom, template default, or null
|
||
sslType: deployConfig.sslType,
|
||
dnsType: deployConfig.dnsType,
|
||
tailscaleOnly: deployConfig.tailscaleOnly || false // Tailscale-only access restriction
|
||
}
|
||
};
|
||
|
||
// Call deployment API
|
||
const response = await fetch('/api/apps/deploy', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(apiDeployConfig)
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// Add to saved apps (store IP for later deletion)
|
||
const newApp = {
|
||
id: deployConfig.subdomain, // Use subdomain as ID
|
||
name: appTemplate.name,
|
||
logo: `/assets/${appTemplate.id}.png`,
|
||
containerId: result.containerId,
|
||
url: result.url,
|
||
ip: deployConfig.ip, // Store IP for DNS record deletion
|
||
appTemplate: appTemplate.id, // Store original template ID
|
||
tailscaleOnly: deployConfig.tailscaleOnly || false // Tailscale protection status
|
||
};
|
||
customApps.push(newApp);
|
||
localStorage.setItem(APPS_KEY, JSON.stringify(customApps));
|
||
|
||
// Add to APPS array and rebuild grid
|
||
// Access APPS from parent scope via window
|
||
if (window.APPS && !window.APPS.some(a => a.id === appTemplate.id)) {
|
||
window.APPS.push(newApp);
|
||
if (typeof window.buildGrid === 'function') {
|
||
window.buildGrid();
|
||
}
|
||
if (typeof window.refreshAll === 'function') {
|
||
setTimeout(() => window.refreshAll(), 500);
|
||
}
|
||
}
|
||
|
||
// Show success with URL (and warning if DNS failed)
|
||
let message = `${appTemplate.name} deployed successfully!\nURL: ${result.url}`;
|
||
if (result.warning) {
|
||
message += `\n\n⚠ Warning: ${result.warning}`;
|
||
}
|
||
|
||
showNotification(message, 'success', 8000);
|
||
|
||
// For HTTPS URLs, check SSL certificate status
|
||
if (result.url && result.url.startsWith('https://')) {
|
||
checkSSLCertificate(result.url, appTemplate.name);
|
||
}
|
||
|
||
// Show setup instructions if available
|
||
if (result.setupInstructions && result.setupInstructions.length > 0) {
|
||
setTimeout(() => {
|
||
const instructions = result.setupInstructions.join('\n');
|
||
alert(`Setup Instructions for ${appTemplate.name}:\n\n${instructions}`);
|
||
}, 1000);
|
||
}
|
||
} else {
|
||
throw new Error(result.error || 'Deployment failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Deployment error:', error);
|
||
showNotification(
|
||
`Failed to deploy ${appTemplate.name}: ${error.message}`,
|
||
'error',
|
||
8000
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check SSL certificate status and notify when ready
|
||
async function checkSSLCertificate(url, appName) {
|
||
showNotification(`⏳ Generating SSL certificate for ${appName}...`, 'warning', 60000);
|
||
|
||
let attempts = 0;
|
||
const maxAttempts = 12; // 60 seconds total (5 second intervals)
|
||
|
||
const checkCert = async () => {
|
||
attempts++;
|
||
|
||
try {
|
||
// Try to fetch the URL - if SSL works, this will succeed
|
||
const response = await fetch(url, {
|
||
method: 'HEAD',
|
||
mode: 'no-cors' // Avoid CORS issues
|
||
});
|
||
|
||
// If we get here, SSL is working
|
||
showNotification(`✅ ${appName} is ready! SSL certificate generated.`, 'success', 5000);
|
||
return true;
|
||
} catch (error) {
|
||
// SSL not ready yet
|
||
if (attempts < maxAttempts) {
|
||
setTimeout(checkCert, 5000); // Check again in 5 seconds
|
||
} else {
|
||
showNotification(
|
||
`⚠️ ${appName} deployed but SSL certificate may still be generating.\nTry refreshing in a moment if you see a certificate error.`,
|
||
'warning',
|
||
10000
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Start checking after 3 seconds (give Caddy time to start)
|
||
setTimeout(checkCert, 3000);
|
||
}
|
||
|
||
// Helper function to show notifications
|
||
function showNotification(text, type = 'info', duration = 3000) {
|
||
const existingNotif = document.querySelector('.deploy-notification');
|
||
if (existingNotif) existingNotif.remove();
|
||
|
||
const colors = {
|
||
info: { bg: '#2196F3', fg: '#fff' },
|
||
success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' },
|
||
error: { bg: '#f44336', fg: '#fff' },
|
||
warning: { bg: '#ff9800', fg: '#fff' }
|
||
};
|
||
|
||
const msg = document.createElement('div');
|
||
msg.className = 'deploy-notification';
|
||
msg.textContent = text;
|
||
msg.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: ${colors[type].bg};
|
||
color: ${colors[type].fg};
|
||
padding: 16px 24px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, .3);
|
||
z-index: 10000;
|
||
animation: slideIn 0.3s ease-out;
|
||
max-width: 400px;
|
||
white-space: pre-line;
|
||
font-size: 14px;
|
||
`;
|
||
document.body.appendChild(msg);
|
||
if (duration > 0) {
|
||
setTimeout(() => msg.remove(), duration);
|
||
}
|
||
}
|
||
|
||
// Load custom apps on startup
|
||
function loadCustomApps() {
|
||
const customApps = JSON.parse(localStorage.getItem(APPS_KEY) || '[]');
|
||
customApps.forEach(app => {
|
||
if (!window.APPS.some(a => a.id === app.id)) {
|
||
window.APPS.push(app);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Event listeners
|
||
document.getElementById('add-service-btn')?.addEventListener('click', () => {
|
||
buildAppSelector();
|
||
modal.classList.add('show');
|
||
});
|
||
|
||
document.getElementById('app-selector-cancel')?.addEventListener('click', () => {
|
||
modal.classList.remove('show');
|
||
});
|
||
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
|
||
// Deploy modal event listeners
|
||
const deployModal = document.getElementById('app-deploy-modal');
|
||
|
||
document.getElementById('app-deploy-cancel')?.addEventListener('click', () => {
|
||
deployModal.classList.remove('show');
|
||
});
|
||
|
||
document.getElementById('app-deploy-confirm')?.addEventListener('click', () => {
|
||
// Get user configuration
|
||
const appTemplate = JSON.parse(deployModal.dataset.appTemplate);
|
||
const deployConfig = {
|
||
appTemplate: appTemplate,
|
||
subdomain: document.getElementById('deploy-subdomain').value.trim(),
|
||
dnsType: document.querySelector('input[name="dns-type"]:checked').value,
|
||
sslType: document.querySelector('input[name="ssl-type"]:checked').value,
|
||
ip: document.getElementById('deploy-ip').value.trim(),
|
||
port: document.getElementById('deploy-port').value.trim(),
|
||
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked
|
||
};
|
||
|
||
// Validate subdomain
|
||
if (!deployConfig.subdomain) {
|
||
alert('Please enter a subdomain or domain name');
|
||
return;
|
||
}
|
||
|
||
// Close deploy modal
|
||
deployModal.classList.remove('show');
|
||
|
||
// Start deployment
|
||
addAppToGrid(deployConfig);
|
||
});
|
||
|
||
deployModal?.addEventListener('click', (e) => {
|
||
if (e.target === deployModal) deployModal.classList.remove('show');
|
||
});
|
||
|
||
// Load custom apps on page load
|
||
loadCustomApps();
|
||
})();
|
||
|
||
// ========== IMPORT/EXPORT FUNCTIONALITY ==========
|
||
(function() {
|
||
// Export dashboard configuration
|
||
function exportDashboard() {
|
||
const exportData = {
|
||
version: '1.0.0',
|
||
exportDate: new Date().toISOString(),
|
||
services: window.APPS || [],
|
||
customServices: JSON.parse(localStorage.getItem('custom-services') || '[]'),
|
||
customApps: JSON.parse(localStorage.getItem('custom-apps') || '[]'),
|
||
weatherZip: localStorage.getItem('weather-zip') || '',
|
||
theme: localStorage.getItem('theme') || 'dark',
|
||
// Note: API tokens are intentionally NOT exported for security
|
||
};
|
||
|
||
const dataStr = JSON.stringify(exportData, null, 2);
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
|
||
alert('✅ Dashboard exported successfully!\n\nNote: API tokens are not included for security reasons.');
|
||
}
|
||
|
||
// Import dashboard configuration
|
||
function importDashboard() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'application/json,.json';
|
||
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
const text = await file.text();
|
||
const importData = JSON.parse(text);
|
||
|
||
// Validate import data
|
||
if (!importData.version || !importData.services) {
|
||
throw new Error('Invalid dashboard backup file');
|
||
}
|
||
|
||
// Confirm import
|
||
const confirmed = confirm(
|
||
`Import dashboard configuration?\n\n` +
|
||
`Export Date: ${new Date(importData.exportDate).toLocaleString()}\n` +
|
||
`Services: ${importData.services.length}\n` +
|
||
`Custom Apps: ${(importData.customApps || []).length}\n\n` +
|
||
`⚠️ This will replace your current dashboard configuration.\n` +
|
||
`API tokens will need to be reconfigured.`
|
||
);
|
||
|
||
if (!confirmed) return;
|
||
|
||
// Import services to API
|
||
try {
|
||
const response = await fetch('/api/services', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(importData.services)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.warn('Could not save services to API, saving locally only');
|
||
}
|
||
} catch (err) {
|
||
console.warn('API not available, saving locally only:', err);
|
||
}
|
||
|
||
// Import to localStorage
|
||
if (importData.customServices) {
|
||
localStorage.setItem('custom-services', JSON.stringify(importData.customServices));
|
||
}
|
||
if (importData.customApps) {
|
||
localStorage.setItem('custom-apps', JSON.stringify(importData.customApps));
|
||
}
|
||
if (importData.weatherZip) {
|
||
localStorage.setItem('weather-zip', importData.weatherZip);
|
||
}
|
||
if (importData.theme) {
|
||
localStorage.setItem('theme', importData.theme);
|
||
}
|
||
|
||
// Update APPS array
|
||
window.APPS = importData.services;
|
||
|
||
alert('✅ Dashboard imported successfully!\n\nThe page will now reload.');
|
||
|
||
// Reload page to apply changes
|
||
window.location.reload();
|
||
|
||
} catch (err) {
|
||
alert(`❌ Import failed: ${err.message}\n\nPlease check the file and try again.`);
|
||
console.error('Import error:', err);
|
||
}
|
||
};
|
||
|
||
input.click();
|
||
}
|
||
|
||
// Add event listeners for import/export buttons
|
||
document.getElementById('export-dashboard')?.addEventListener('click', exportDashboard);
|
||
document.getElementById('import-dashboard')?.addEventListener('click', importDashboard);
|
||
|
||
// Reload Caddy button handler
|
||
document.getElementById('reload-caddy')?.addEventListener('click', async () => {
|
||
const button = document.getElementById('reload-caddy');
|
||
const originalText = button.textContent;
|
||
|
||
try {
|
||
button.textContent = '⏳ Reloading...';
|
||
button.disabled = true;
|
||
|
||
const response = await fetch('/api/caddy/reload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
button.textContent = '✅ Reloaded!';
|
||
setTimeout(() => {
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
}, 2000);
|
||
} else {
|
||
throw new Error(result.error || 'Reload failed');
|
||
}
|
||
} catch (error) {
|
||
button.textContent = '❌ Failed';
|
||
alert(`Failed to reload Caddy: ${error.message}`);
|
||
setTimeout(() => {
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
}, 2000);
|
||
}
|
||
});
|
||
})();
|
||
|
||
// ========== ERROR LOG VIEWER ==========
|
||
(function() {
|
||
const modal = document.getElementById('error-log-modal');
|
||
const content = document.getElementById('error-log-content');
|
||
const viewBtn = document.getElementById('view-error-logs');
|
||
const refreshBtn = document.getElementById('error-log-refresh');
|
||
const clearBtn = document.getElementById('error-log-clear');
|
||
const closeBtn = document.getElementById('error-log-close');
|
||
|
||
async function loadErrorLogs() {
|
||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
||
|
||
try {
|
||
const response = await fetch('/api/error-logs');
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.logs) {
|
||
if (data.logs.length === 0) {
|
||
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
|
||
} else {
|
||
content.innerHTML = data.logs.map(log => {
|
||
const date = new Date(log.timestamp).toLocaleString();
|
||
return `
|
||
<div class="log-entry error">
|
||
<span class="log-timestamp">${date}</span>
|
||
<span class="log-level">ERROR</span>
|
||
<div class="log-message">
|
||
<strong>${log.context}</strong>: ${log.error}
|
||
${log.details ? `<br><small style="opacity: 0.7;">${log.details}</small>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
} else {
|
||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||
}
|
||
} catch (error) {
|
||
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function clearErrorLogs() {
|
||
if (!confirm('Clear all error logs?')) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/error-logs', { method: 'DELETE' });
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showNotification('✅ Error logs cleared', 'success', 3000);
|
||
loadErrorLogs();
|
||
} else {
|
||
showNotification('❌ Failed to clear logs', 'error', 3000);
|
||
}
|
||
} catch (error) {
|
||
showNotification(`❌ Error: ${error.message}`, 'error', 3000);
|
||
}
|
||
}
|
||
|
||
viewBtn?.addEventListener('click', () => {
|
||
modal.classList.add('show');
|
||
loadErrorLogs();
|
||
});
|
||
|
||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||
closeBtn?.addEventListener('click', () => modal.classList.remove('show'));
|
||
|
||
// Close on background click
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
})();
|
||
|
||
// ========== ARR STACK SETUP ==========
|
||
(function() {
|
||
const modal = document.getElementById('arr-setup-modal');
|
||
const openBtn = document.getElementById('arr-setup-btn');
|
||
const cancelBtn = document.getElementById('arr-setup-cancel');
|
||
const runBtn = document.getElementById('arr-setup-run');
|
||
const autoConnectBtn = document.getElementById('arr-auto-connect');
|
||
const resultDiv = document.getElementById('arr-setup-result');
|
||
const localSection = document.getElementById('arr-local-section');
|
||
const localServicesDiv = document.getElementById('arr-local-services');
|
||
|
||
// Load external service URLs from services.json
|
||
async function loadExternalServices() {
|
||
try {
|
||
const response = await fetch('/api/services');
|
||
const data = await response.json();
|
||
|
||
if (data.services) {
|
||
const radarr = data.services.find(s => s.id === 'radarr' && s.isExternal);
|
||
const sonarr = data.services.find(s => s.id === 'sonarr' && s.isExternal);
|
||
|
||
if (radarr?.externalUrl) {
|
||
document.getElementById('arr-radarr-url').value = radarr.externalUrl;
|
||
}
|
||
if (sonarr?.externalUrl) {
|
||
document.getElementById('arr-sonarr-url').value = sonarr.externalUrl;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('Could not load external services:', e);
|
||
}
|
||
}
|
||
|
||
// Check for local containers and Overseerr status
|
||
async function checkLocalServices() {
|
||
const statusIcon = document.getElementById('overseerr-status-icon');
|
||
const statusText = document.getElementById('overseerr-status-text');
|
||
|
||
try {
|
||
const response = await fetch('/api/arr/detect');
|
||
const data = await response.json();
|
||
|
||
// Update Overseerr status
|
||
if (data.services?.overseerr) {
|
||
statusIcon.textContent = '✅';
|
||
statusText.textContent = 'Running on port ' + data.services.overseerr.port;
|
||
statusText.style.color = 'var(--ok-fg)';
|
||
} else {
|
||
statusIcon.textContent = '❌';
|
||
statusText.textContent = 'Not running - deploy Overseerr first';
|
||
statusText.style.color = 'var(--bad-fg)';
|
||
localSection.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Check for local Radarr/Sonarr
|
||
const localServices = [];
|
||
if (data.summary?.radarrReady) {
|
||
localServices.push({ name: 'Radarr', icon: '🎬', port: data.services.radarr?.port });
|
||
}
|
||
if (data.summary?.sonarrReady) {
|
||
localServices.push({ name: 'Sonarr', icon: '📺', port: data.services.sonarr?.port });
|
||
}
|
||
|
||
// Show local section if any local services found
|
||
if (localServices.length > 0) {
|
||
localSection.style.display = 'block';
|
||
localServicesDiv.innerHTML = localServices.map(s =>
|
||
`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 10px; background: color-mix(in srgb, #2ecc71 20%, transparent); border-radius: 6px; font-size: 0.85rem;">
|
||
${s.icon} ${s.name} <span style="color: var(--muted);">:${s.port}</span>
|
||
</span>`
|
||
).join('');
|
||
} else {
|
||
localSection.style.display = 'none';
|
||
}
|
||
|
||
return data;
|
||
} catch (e) {
|
||
statusIcon.textContent = '⚠️';
|
||
statusText.textContent = 'Could not check status';
|
||
localSection.style.display = 'none';
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// One-click auto-connect for local containers
|
||
async function autoConnect() {
|
||
autoConnectBtn.disabled = true;
|
||
autoConnectBtn.innerHTML = '<span class="brand-spinner"></span> Auto-connecting...';
|
||
resultDiv.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/arr/auto-setup', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const configured = [];
|
||
if (data.configResults?.radarr === 'configured') configured.push('Radarr');
|
||
if (data.configResults?.sonarr === 'configured') configured.push('Sonarr');
|
||
|
||
resultDiv.innerHTML = `✅ Auto-connected: ${configured.join(', ')}!<br><small style="color: var(--muted);">Quality profiles and root folders configured automatically.</small>`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
} else {
|
||
resultDiv.innerHTML = `⚠️ ${data.error || 'Auto-connect failed'}`;
|
||
if (data.setupUrl) {
|
||
resultDiv.innerHTML += `<br><a href="${data.setupUrl}" target="_blank" style="color: var(--accent);">Open Overseerr to complete setup →</a>`;
|
||
}
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
}
|
||
} catch (e) {
|
||
resultDiv.innerHTML = `❌ Error: ${e.message}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
|
||
autoConnectBtn.disabled = false;
|
||
autoConnectBtn.innerHTML = '⚡ One-Click Auto-Connect';
|
||
}
|
||
|
||
// Legacy check function for external services
|
||
async function checkOverseerr() {
|
||
return checkLocalServices();
|
||
}
|
||
|
||
// Test connection to external service
|
||
async function testConnection(service) {
|
||
const urlInput = document.getElementById(`arr-${service}-url`);
|
||
const keyInput = document.getElementById(`arr-${service}-key`);
|
||
const statusSpan = document.getElementById(`${service}-test-status`);
|
||
|
||
const url = urlInput.value.trim();
|
||
const apiKey = keyInput.value.trim();
|
||
|
||
if (!url || !apiKey) {
|
||
statusSpan.innerHTML = '<span style="color: var(--bad-fg);">Enter URL and API key</span>';
|
||
return;
|
||
}
|
||
|
||
statusSpan.innerHTML = '<span class="brand-spinner"></span>';
|
||
|
||
try {
|
||
const response = await fetch('/api/arr/test-connection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ service, url, apiKey })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
statusSpan.innerHTML = '<span style="color: var(--ok-fg);">✓ Connected</span>';
|
||
} else {
|
||
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${data.error}</span>`;
|
||
}
|
||
} catch (e) {
|
||
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// Connect to Overseerr with external services
|
||
async function connectToOverseerr() {
|
||
runBtn.disabled = true;
|
||
runBtn.innerHTML = '<span class="brand-spinner"></span> Connecting...';
|
||
resultDiv.style.display = 'none';
|
||
|
||
const radarrUrl = document.getElementById('arr-radarr-url').value.trim();
|
||
const radarrKey = document.getElementById('arr-radarr-key').value.trim();
|
||
const sonarrUrl = document.getElementById('arr-sonarr-url').value.trim();
|
||
const sonarrKey = document.getElementById('arr-sonarr-key').value.trim();
|
||
|
||
if (!radarrKey && !sonarrKey) {
|
||
resultDiv.innerHTML = '⚠️ Enter at least one API key (Radarr or Sonarr)';
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
runBtn.disabled = false;
|
||
runBtn.innerHTML = '⚡ Connect to Overseerr';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/arr/configure-overseerr', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
radarr: radarrUrl && radarrKey ? { url: radarrUrl, apiKey: radarrKey } : null,
|
||
sonarr: sonarrUrl && sonarrKey ? { url: sonarrUrl, apiKey: sonarrKey } : null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const results = [];
|
||
if (data.results.radarr) results.push(`Radarr: ${data.results.radarr}`);
|
||
if (data.results.sonarr) results.push(`Sonarr: ${data.results.sonarr}`);
|
||
|
||
resultDiv.innerHTML = `✅ Configuration sent to Overseerr!<br><small style="color: var(--muted);">${results.join(', ')}</small>`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
|
||
if (data.note) {
|
||
resultDiv.innerHTML += `<br><small style="color: var(--accent);">${data.note}</small>`;
|
||
}
|
||
} else {
|
||
// Build detailed error message
|
||
let errorMsg = data.error || data.message || 'Configuration failed';
|
||
if (data.results) {
|
||
const failures = [];
|
||
if (data.results.radarr && data.results.radarr !== 'configured') {
|
||
failures.push(`Radarr: ${data.results.radarr}`);
|
||
}
|
||
if (data.results.sonarr && data.results.sonarr !== 'configured') {
|
||
failures.push(`Sonarr: ${data.results.sonarr}`);
|
||
}
|
||
if (failures.length > 0) {
|
||
errorMsg += '<br><small style="color: var(--muted);">' + failures.join('<br>') + '</small>';
|
||
}
|
||
}
|
||
resultDiv.innerHTML = `⚠️ ${errorMsg}`;
|
||
if (data.hint) resultDiv.innerHTML += `<br><small>${data.hint}</small>`;
|
||
if (data.setupUrl) resultDiv.innerHTML += `<br><a href="${data.setupUrl}" target="_blank" style="color: var(--accent);">Open Overseerr →</a>`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
}
|
||
} catch (e) {
|
||
resultDiv.innerHTML = `❌ Error: ${e.message}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
|
||
runBtn.disabled = false;
|
||
runBtn.innerHTML = '⚡ Connect to Overseerr';
|
||
}
|
||
|
||
// Event listeners
|
||
openBtn?.addEventListener('click', () => {
|
||
modal.classList.add('show');
|
||
resultDiv.style.display = 'none';
|
||
checkLocalServices();
|
||
loadExternalServices();
|
||
});
|
||
|
||
cancelBtn?.addEventListener('click', () => modal.classList.remove('show'));
|
||
runBtn?.addEventListener('click', connectToOverseerr);
|
||
autoConnectBtn?.addEventListener('click', autoConnect);
|
||
|
||
document.getElementById('arr-radarr-test')?.addEventListener('click', () => testConnection('radarr'));
|
||
document.getElementById('arr-sonarr-test')?.addEventListener('click', () => testConnection('sonarr'));
|
||
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
})();
|
||
|
||
// ========== NOTIFICATION SETTINGS ==========
|
||
(function() {
|
||
const modal = document.getElementById('notifications-modal');
|
||
const openBtn = document.getElementById('manage-notifications');
|
||
const saveBtn = document.getElementById('notifications-save');
|
||
const cancelBtn = document.getElementById('notifications-cancel');
|
||
|
||
// Provider toggle handlers
|
||
['discord', 'telegram', 'ntfy'].forEach(provider => {
|
||
const checkbox = document.getElementById(`${provider}-enabled`);
|
||
const config = document.getElementById(`${provider}-config`);
|
||
|
||
checkbox?.addEventListener('change', () => {
|
||
config.style.display = checkbox.checked ? 'block' : 'none';
|
||
});
|
||
});
|
||
|
||
// Health check toggle
|
||
const healthCheckEnabled = document.getElementById('health-check-enabled');
|
||
const healthCheckConfig = document.getElementById('health-check-config');
|
||
healthCheckEnabled?.addEventListener('change', () => {
|
||
healthCheckConfig.style.opacity = healthCheckEnabled.checked ? '1' : '0.5';
|
||
});
|
||
|
||
// Load notification config from API
|
||
async function loadNotificationConfig() {
|
||
try {
|
||
const response = await fetch('/api/notifications/config');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const config = data.config;
|
||
|
||
// Master toggle
|
||
document.getElementById('notifications-enabled').checked = config.enabled;
|
||
|
||
// Providers
|
||
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
|
||
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
|
||
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
|
||
|
||
// Show/hide config sections
|
||
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
|
||
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
|
||
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
|
||
|
||
// ntfy server URL
|
||
if (config.providers?.ntfy?.serverUrl) {
|
||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||
}
|
||
|
||
// Health check
|
||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||
if (config.healthCheck?.intervalMinutes) {
|
||
document.getElementById('health-check-interval').value = config.healthCheck.intervalMinutes;
|
||
}
|
||
if (config.healthCheck?.lastCheck) {
|
||
document.getElementById('health-check-status').textContent =
|
||
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
||
}
|
||
|
||
// Events
|
||
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
|
||
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
|
||
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
|
||
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load notification config:', error);
|
||
}
|
||
}
|
||
|
||
// Load notification history
|
||
async function loadNotificationHistory() {
|
||
try {
|
||
const response = await fetch('/api/notifications/history?limit=10');
|
||
const data = await response.json();
|
||
|
||
const container = document.getElementById('notification-history');
|
||
if (data.success && data.history?.length > 0) {
|
||
container.innerHTML = data.history.map(item => {
|
||
const date = new Date(item.timestamp).toLocaleString();
|
||
const typeColors = {
|
||
success: 'var(--ok-fg)',
|
||
error: 'var(--bad-fg)',
|
||
warning: '#f39c12',
|
||
info: 'var(--accent)'
|
||
};
|
||
return `
|
||
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
||
<span style="color: ${typeColors[item.type] || 'var(--muted)'}">${item.type === 'success' ? '✓' : item.type === 'error' ? '✗' : 'ℹ'}</span>
|
||
<div style="flex: 1; min-width: 0;">
|
||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${item.title}</div>
|
||
<div style="font-size: 0.7rem; color: var(--muted);">${date}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
} else {
|
||
container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load notification history:', error);
|
||
}
|
||
}
|
||
|
||
// Save notification config
|
||
async function saveNotificationConfig() {
|
||
try {
|
||
const config = {
|
||
enabled: document.getElementById('notifications-enabled').checked,
|
||
providers: {
|
||
discord: {
|
||
enabled: document.getElementById('discord-enabled').checked,
|
||
webhookUrl: document.getElementById('discord-webhook').value.trim()
|
||
},
|
||
telegram: {
|
||
enabled: document.getElementById('telegram-enabled').checked,
|
||
botToken: document.getElementById('telegram-bot-token').value.trim(),
|
||
chatId: document.getElementById('telegram-chat-id').value.trim()
|
||
},
|
||
ntfy: {
|
||
enabled: document.getElementById('ntfy-enabled').checked,
|
||
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
|
||
topic: document.getElementById('ntfy-topic').value.trim()
|
||
}
|
||
},
|
||
events: {
|
||
containerDown: document.getElementById('event-container-down').checked,
|
||
containerUp: document.getElementById('event-container-up').checked,
|
||
deploymentSuccess: document.getElementById('event-deploy-success').checked,
|
||
deploymentFailed: document.getElementById('event-deploy-failed').checked
|
||
},
|
||
healthCheck: {
|
||
enabled: document.getElementById('health-check-enabled').checked,
|
||
intervalMinutes: parseInt(document.getElementById('health-check-interval').value) || 5
|
||
}
|
||
};
|
||
|
||
const response = await fetch('/api/notifications/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
showNotification('Notification settings saved', 'success', 3000);
|
||
modal.classList.remove('show');
|
||
} else {
|
||
showNotification(`Failed to save: ${data.error}`, 'error', 3000);
|
||
}
|
||
} catch (error) {
|
||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||
}
|
||
}
|
||
|
||
// Test notification handlers
|
||
async function testProvider(provider) {
|
||
try {
|
||
const response = await fetch('/api/notifications/test', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ provider })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
showNotification(`Test ${provider} notification sent!`, 'success', 3000);
|
||
} else {
|
||
showNotification(`Test failed: ${data.error}`, 'error', 3000);
|
||
}
|
||
} catch (error) {
|
||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||
}
|
||
}
|
||
|
||
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
|
||
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
|
||
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
|
||
|
||
// Health check now button
|
||
document.getElementById('health-check-now')?.addEventListener('click', async () => {
|
||
try {
|
||
const response = await fetch('/api/notifications/health-check', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
document.getElementById('health-check-status').textContent =
|
||
`Last check: ${new Date(data.lastCheck).toLocaleString()} (${data.containersMonitored} containers)`;
|
||
showNotification('Health check completed', 'success', 2000);
|
||
}
|
||
} catch (error) {
|
||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||
}
|
||
});
|
||
|
||
// Modal handlers
|
||
openBtn?.addEventListener('click', () => {
|
||
modal.classList.add('show');
|
||
loadNotificationConfig();
|
||
loadNotificationHistory();
|
||
});
|
||
|
||
saveBtn?.addEventListener('click', saveNotificationConfig);
|
||
cancelBtn?.addEventListener('click', () => modal.classList.remove('show'));
|
||
|
||
// Close on background click
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
})();
|
||
|
||
// ========== BACKUP/RESTORE ==========
|
||
(function() {
|
||
const modal = document.getElementById('backup-modal');
|
||
const openBtn = document.getElementById('backup-restore-btn');
|
||
const cancelBtn = document.getElementById('backup-cancel');
|
||
const exportBtn = document.getElementById('backup-export-btn');
|
||
const selectFileBtn = document.getElementById('backup-select-file');
|
||
const fileInput = document.getElementById('backup-file-input');
|
||
const fileNameDiv = document.getElementById('backup-file-name');
|
||
const previewDiv = document.getElementById('backup-preview');
|
||
const previewContent = document.getElementById('backup-preview-content');
|
||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||
const resultDiv = document.getElementById('backup-result');
|
||
|
||
let selectedBackup = null;
|
||
|
||
// Open modal
|
||
openBtn?.addEventListener('click', () => {
|
||
modal.classList.add('show');
|
||
resultDiv.style.display = 'none';
|
||
previewDiv.style.display = 'none';
|
||
fileNameDiv.style.display = 'none';
|
||
selectedBackup = null;
|
||
});
|
||
|
||
// Close modal
|
||
cancelBtn?.addEventListener('click', () => modal.classList.remove('show'));
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.classList.remove('show');
|
||
});
|
||
|
||
// Export backup
|
||
exportBtn?.addEventListener('click', async () => {
|
||
exportBtn.disabled = true;
|
||
exportBtn.innerHTML = '<span class="brand-spinner"></span> Exporting...';
|
||
|
||
try {
|
||
const response = await fetch('/api/backup/export');
|
||
const data = await response.json();
|
||
|
||
// Create download
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
resultDiv.innerHTML = '✅ Backup downloaded successfully!';
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
} catch (e) {
|
||
resultDiv.innerHTML = `❌ Export failed: ${e.message}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
|
||
exportBtn.disabled = false;
|
||
exportBtn.innerHTML = '⬇️ Download Backup';
|
||
});
|
||
|
||
// Select file button
|
||
selectFileBtn?.addEventListener('click', () => fileInput.click());
|
||
|
||
// File selected
|
||
fileInput?.addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
fileNameDiv.textContent = `📄 ${file.name}`;
|
||
fileNameDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
|
||
try {
|
||
const text = await file.text();
|
||
const backup = JSON.parse(text);
|
||
|
||
// Validate and preview
|
||
const response = await fetch('/api/backup/preview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(backup)
|
||
});
|
||
|
||
const preview = await response.json();
|
||
|
||
if (preview.success) {
|
||
selectedBackup = backup;
|
||
|
||
let html = `<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">
|
||
Exported: ${new Date(backup.exportedAt).toLocaleString()}
|
||
</div>`;
|
||
|
||
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||
for (const [key, info] of Object.entries(preview.preview.files)) {
|
||
const icon = info.action === 'create' ? '🆕' : '📝';
|
||
html += `<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">
|
||
${icon} ${info.description}
|
||
</span>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
if (preview.preview.serviceCount) {
|
||
html += `<div style="margin-top: 8px; font-size: 0.8rem; color: var(--accent);">
|
||
${preview.preview.serviceCount} services in backup
|
||
</div>`;
|
||
}
|
||
|
||
previewContent.innerHTML = html;
|
||
previewDiv.style.display = 'block';
|
||
} else {
|
||
resultDiv.innerHTML = `⚠️ Invalid backup file: ${preview.error}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
previewDiv.style.display = 'none';
|
||
}
|
||
} catch (e) {
|
||
resultDiv.innerHTML = `❌ Could not read file: ${e.message}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
previewDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Restore backup
|
||
restoreBtn?.addEventListener('click', async () => {
|
||
if (!selectedBackup) return;
|
||
|
||
if (!confirm('This will overwrite your current configuration. Continue?')) {
|
||
return;
|
||
}
|
||
|
||
restoreBtn.disabled = true;
|
||
restoreBtn.innerHTML = '<span class="brand-spinner"></span> Restoring...';
|
||
|
||
try {
|
||
const reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true;
|
||
|
||
const response = await fetch('/api/backup/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
backup: selectedBackup,
|
||
options: { reloadCaddy }
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
let msg = `✅ ${data.message}`;
|
||
if (data.results.caddyReloaded) {
|
||
msg += '<br><small style="color: var(--muted);">Caddy configuration reloaded</small>';
|
||
}
|
||
resultDiv.innerHTML = msg;
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
|
||
// Refresh page after short delay
|
||
setTimeout(() => location.reload(), 2000);
|
||
} else {
|
||
resultDiv.innerHTML = `⚠️ ${data.message}`;
|
||
if (data.results?.errors?.length > 0) {
|
||
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(e => `${e.file}: ${e.error}`).join(', ') + '</small>';
|
||
}
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
}
|
||
|
||
resultDiv.style.display = 'block';
|
||
} catch (e) {
|
||
resultDiv.innerHTML = `❌ Restore failed: ${e.message}`;
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
|
||
restoreBtn.disabled = false;
|
||
restoreBtn.innerHTML = '⚡ Restore Configuration';
|
||
});
|
||
})();
|
||
|
||
// ========== CONTAINER STATS ==========
|
||
(function() {
|
||
const modal = document.getElementById('stats-modal');
|
||
const openBtn = document.getElementById('container-stats-btn');
|
||
const cancelBtn = document.getElementById('stats-cancel');
|
||
const refreshBtn = document.getElementById('stats-refresh-btn');
|
||
const autoRefreshCheckbox = document.getElementById('stats-auto-refresh');
|
||
const container = document.getElementById('stats-container');
|
||
const lastUpdateSpan = document.getElementById('stats-last-update');
|
||
|
||
let refreshInterval = null;
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function getCpuColor(percent) {
|
||
if (percent < 30) return '#2ecc71';
|
||
if (percent < 70) return '#f39c12';
|
||
return '#e74c3c';
|
||
}
|
||
|
||
function getMemColor(percent) {
|
||
if (percent < 50) return '#2ecc71';
|
||
if (percent < 80) return '#f39c12';
|
||
return '#e74c3c';
|
||
}
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const response = await fetch('/api/stats/containers');
|
||
const data = await response.json();
|
||
|
||
if (!data.success || !data.stats || data.stats.length === 0) {
|
||
container.innerHTML = `
|
||
<div style="text-align: center; padding: 40px; color: var(--muted);">
|
||
No running containers found
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||
|
||
for (const stat of data.stats) {
|
||
const cpuColor = getCpuColor(stat.cpu.percent);
|
||
const memColor = getMemColor(stat.memory.percent);
|
||
|
||
html += `
|
||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||
<span style="font-weight: 600; flex: 1;">${stat.name}</span>
|
||
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${stat.status}</span>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||
<!-- CPU -->
|
||
<div>
|
||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||
<div style="height: 100%; width: ${Math.min(stat.cpu.percent, 100)}%; background: ${cpuColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
||
</div>
|
||
<span style="font-size: 0.8rem; font-weight: 500; color: ${cpuColor}; min-width: 45px; text-align: right;">${stat.cpu.percent.toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Memory -->
|
||
<div>
|
||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||
<div style="height: 100%; width: ${Math.min(stat.memory.percent, 100)}%; background: ${memColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
||
</div>
|
||
<span style="font-size: 0.8rem; font-weight: 500; color: ${memColor}; min-width: 45px; text-align: right;">${stat.memory.percent.toFixed(1)}%</span>
|
||
</div>
|
||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">
|
||
${formatBytes(stat.memory.used)} / ${formatBytes(stat.memory.limit)}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Network -->
|
||
<div>
|
||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||
<div style="font-size: 0.8rem;">
|
||
<span style="color: #3498db;">↓ ${formatBytes(stat.network.rx)}</span>
|
||
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
||
<span style="color: #e74c3c;">↑ ${formatBytes(stat.network.tx)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
lastUpdateSpan.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||
|
||
} catch (e) {
|
||
container.innerHTML = `
|
||
<div style="text-align: center; padding: 40px; color: var(--bad-fg);">
|
||
❌ Failed to load stats: ${e.message}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function startAutoRefresh() {
|
||
if (refreshInterval) clearInterval(refreshInterval);
|
||
if (autoRefreshCheckbox?.checked) {
|
||
refreshInterval = setInterval(loadStats, 5000);
|
||
}
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
refreshInterval = null;
|
||
}
|
||
}
|
||
|
||
// Open modal
|
||
openBtn?.addEventListener('click', () => {
|
||
modal.classList.add('show');
|
||
loadStats();
|
||
startAutoRefresh();
|
||
});
|
||
|
||
// Close modal
|
||
cancelBtn?.addEventListener('click', () => {
|
||
modal.classList.remove('show');
|
||
stopAutoRefresh();
|
||
});
|
||
|
||
modal?.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
modal.classList.remove('show');
|
||
stopAutoRefresh();
|
||
}
|
||
});
|
||
|
||
// Manual refresh
|
||
refreshBtn?.addEventListener('click', loadStats);
|
||
|
||
// Toggle auto-refresh
|
||
autoRefreshCheckbox?.addEventListener('change', () => {
|
||
if (autoRefreshCheckbox.checked) {
|
||
startAutoRefresh();
|
||
} else {
|
||
stopAutoRefresh();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<!-- User Onboarding Tooltips System -->
|
||
<!-- Driver.js Library (CDN) -->
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.js.iife.js"></script>
|
||
|
||
<!-- Onboarding System Styles -->
|
||
<link rel="stylesheet" href="/css/onboarding.css">
|
||
|
||
<!-- Onboarding System Scripts -->
|
||
<script src="/js/error-handler.js"></script>
|
||
<script src="/js/progress-tracker.js"></script>
|
||
<script src="/js/theme-adapter.js"></script>
|
||
<script src="/js/tooltip-definitions.js"></script>
|
||
<script src="/js/dns-template-selector.js"></script>
|
||
<script src="/js/tour-manager.js"></script>
|
||
<script src="/js/onboarding.js"></script>
|
||
</body>
|
||
|
||
</html>
|