Files
dashcaddy/status/index.html.backup-before-file-logs

8126 lines
303 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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}&timestamps=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>