Files
homelab-manifests/glance-system/glance-kisfenyo.yaml
T
2026-01-27 08:43:02 +01:00

2346 lines
101 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
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.
# Glance Dashboard for Kisfenyo
# Namespace: glance-system
# Domain: kisfenyo.dooplex.hu
# Version: v0.8.4
#
# Features:
# - Custom background image
# - Custom logo
# - Weather widget (Budapest)
# - YouTube subscriptions
# - RSS feeds
# - To-do list
# - iFrames for Cal.com, Google Calendar, Outline
# - Bookmarks/Links to all apps
# - Calendar widget
#
# Authentik Integration:
# 1. Create Application: "Glance Home"
# 2. Create Provider: Proxy Provider with external host https://kisfenyo.dooplex.hu
# 3. Create Outpost: glance-outpost
# 4. Update auth-url annotation with actual outpost service name
---
apiVersion: v1
kind: ConfigMap
metadata:
name: glance-config-kisfenyo
namespace: glance-system
labels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
data:
glance.yml: |
# Glance Configuration
# Documentation: https://github.com/glanceapp/glance/blob/main/docs/configuration.md
server:
host: 0.0.0.0
port: 8080
assets-path: /app/assets
document:
head: |
<style>
/* Glance Quick Launch overlay */
#gql-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 10vh;
z-index: 99999;
}
#gql-box {
width: min(720px, 92vw);
border-radius: 14px;
background: rgba(20, 22, 30, .92);
box-shadow: 0 10px 30px rgba(0,0,0,.35);
overflow: hidden;
backdrop-filter: blur(8px);
}
#gql-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
border: 0;
outline: none;
font-size: 16px;
color: #fff;
background: rgba(0,0,0,.25);
}
#gql-hint {
padding: 8px 16px;
font-size: 12px;
opacity: .75;
color: #fff;
}
#gql-list {
max-height: 360px;
overflow: auto;
padding: 6px;
}
.gql-item {
padding: 10px 10px;
border-radius: 10px;
cursor: pointer;
color: #fff;
display: flex;
gap: 10px;
align-items: baseline;
}
.gql-item:hover, .gql-item.active { background: rgba(255,255,255,.10); }
.gql-title { font-weight: 600; }
.gql-url { font-size: 12px; opacity: .7; word-break: break-all; }
</style>
<script>
(() => {
const MAX_RESULTS = 12;
const overlay = document.createElement('div');
overlay.id = 'gql-overlay';
overlay.innerHTML = `
<div id="gql-box" role="dialog" aria-modal="true">
<input id="gql-input" type="text" autocomplete="off" spellcheck="false" placeholder="Type to search bookmarks…"/>
<div id="gql-hint">↑/↓ to navigate • Enter to open • Esc to close</div>
<div id="gql-list"></div>
</div>
`;
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));
const $ = (sel) => overlay.querySelector(sel);
const input = () => $('#gql-input');
const list = () => $('#gql-list');
let indexed = [];
let activeIndex = 0;
let lastQuery = '';
const BOOKMARKS_INDEX_URL = '/assets/bookmarks.json';
let indexLoaded = false;
let indexLoading = null;
function indexLinksFromDom() {
const anchors = document.querySelectorAll('.widget.widget-type-bookmarks a.bookmarks-link[href]');
indexed = Array.from(anchors).map(a => ({
title: (a.textContent || '').trim(),
url: a.href,
meta: ''
}));
}
function loadBookmarksIndex() {
if (indexLoaded) return Promise.resolve();
if (indexLoading) return indexLoading;
indexLoading = fetch(BOOKMARKS_INDEX_URL, { cache: 'no-store' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
indexed = data.map(x => ({
title: String(x.title ?? x.url ?? ''),
url: String(x.url ?? ''),
meta: [x.page, x.widget, x.group].filter(Boolean).map(v => String(v)).join(' • ')
}));
indexLoaded = true;
})
.catch(e => {
console.warn('Could not load bookmarks index, falling back to DOM only:', e);
indexLinksFromDom();
indexLoaded = true;
});
return indexLoading;
}
// Load ASAP (works even if DOMContentLoaded already happened)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => loadBookmarksIndex());
} else {
loadBookmarksIndex();
}
function normalize(s) {
return String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
}
function score(item, q) {
const t = normalize(item.title);
if (!q) return 0;
if (t === q) return 200;
if (t.startsWith(q)) return 120;
// boost if query matches start of any word
const words = t.split(' ');
if (words.some(w => w.startsWith(q))) return 95;
if (t.includes(q)) return 70;
return -1;
}
function render(q) {
const results = indexed
.map(it => ({ ...it, s: score(it, q) }))
.filter(it => it.s >= 0)
.sort((a,b) => b.s - a.s)
.slice(0, MAX_RESULTS);
activeIndex = 0;
list().innerHTML = results.map((r, i) => `
<div class="gql-item ${i===0 ? 'active' : ''}" data-i="${i}">
<div class="gql-title">${escapeHtml(r.title || r.url)}</div>
<div class="gql-url">${escapeHtml(r.meta || r.url)}</div>
</div>
`).join('');
list().onclick = (e) => {
const item = e.target.closest('.gql-item');
if (!item) return;
const i = Number(item.dataset.i);
openResult(results, i, false);
};
return results;
}
function setActive(i) {
const items = Array.from(list().querySelectorAll('.gql-item'));
items.forEach(el => el.classList.remove('active'));
if (items[i]) {
items[i].classList.add('active');
items[i].scrollIntoView({ block: 'nearest' });
}
}
function openOverlay(withInitialText = '') {
overlay.style.display = 'flex';
input().value = withInitialText;
lastQuery = withInitialText;
// show something instantly
list().innerHTML = `<div class="gql-item active"><div class="gql-title">Loading…</div></div>`;
// then render once index is available
loadBookmarksIndex().then(() => {
render(normalize(input().value));
input().focus();
});
function onInput() {
lastQuery = input().value;
render(normalize(lastQuery));
}
input().oninput = onInput;
input().onkeydown = (e) => {
const items = list().querySelectorAll('.gql-item');
const count = items.length;
if (e.key === 'Escape') { e.preventDefault(); closeOverlay(); return; }
if (e.key === 'ArrowDown' && count) { e.preventDefault(); activeIndex = Math.min(activeIndex+1, count-1); setActive(activeIndex); return; }
if (e.key === 'ArrowUp' && count) { e.preventDefault(); activeIndex = Math.max(activeIndex-1, 0); setActive(activeIndex); return; }
if (e.key === 'Enter') {
e.preventDefault();
const q = normalize(input().value);
const resultsNow = indexed
.map(it => ({ ...it, s: score(it, q) }))
.filter(it => it.s >= 0)
.sort((a,b) => b.s - a.s)
.slice(0, MAX_RESULTS);
openResult(resultsNow, activeIndex);
}
};
overlay.onclick = (e) => { if (e.target === overlay) closeOverlay(); };
}
function openResult(results, i) {
const r = results[i];
if (!r) return;
closeOverlay();
window.open(r.url, '_blank', 'noopener,noreferrer');
}
function closeOverlay() {
overlay.style.display = 'none';
list().innerHTML = '';
activeIndex = 0;
}
function escapeHtml(str) {
return (str || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Global key handler: "just start typing"
window.addEventListener('keydown', (e) => {
if (overlay.style.display === 'flex') return;
// ignore when typing in inputs/textareas or using modifier shortcuts
const tag = (document.activeElement && document.activeElement.tagName || '').toLowerCase();
const typingIntoField = tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable;
if (typingIntoField) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
// Printable character opens overlay with that character
if (e.key.length === 1 && !e.repeat) {
openOverlay(e.key);
e.preventDefault();
return;
}
// Optional: slash to open empty
if (e.key === '/') {
openOverlay('');
e.preventDefault();
}
});
})();
</script>
branding:
logo-url: https://web.dooplex.hu/static/DooPlex_logo_3.png
favicon-url: https://web.dooplex.hu/static/DooPlex_favicon_3.png
app-name: "Kisfenyo's Home"
app-icon-url: https://web.dooplex.hu/static/DooPlex_favicon_3.png
app-background-color: "#132b66"
hide-footer: true
theme:
disable-picker: true
background-color: 210 35 12 # Was: 280 30 15 (purple → blue)
primary-color: 200 70 60 # Was: 280 60 70 (purple → cyan-blue)
positive-color: 150 55 50 # Was: 120 50 50 (green → teal-green, matches better)
negative-color: 0 70 60 # Unchanged (red)
contrast-multiplier: 1.2 # Unchanged
text-saturation-multiplier: 0.8 # Unchanged
custom-css-file: /assets/custom.css
pages:
# ==================== HOME PAGE ====================
- name: Home
slug: home
width: wide
columns:
# ---------- LEFT COLUMN ----------
- size: small
widgets:
# Glance Custom API Widget - System Stats from Prometheus
# Add this widget to your glance.yml configuration under a column's widgets section
#
# Prometheus URL: http://prometheus.mon-system.svc.cluster.local:9090
#
# This widget displays:
# - Hostname & Uptime
# - CPU usage % and Temperature
# - Memory usage %
# - Disk usage for all mount points with progress bars
# - Fan speeds
#
# Note: Make sure your Glance version is v0.8.0+ for all template functions
- type: custom-api
title: DooPlex Server
cache: 30s
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_uname_info
subrequests:
uptime_days:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: floor((time() - node_boot_time_seconds) / 86400)
uptime_hours:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: floor((time() - node_boot_time_seconds) % 86400 / 3600)
cpu:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
memory:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100
cpu_temp:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_hwmon_temp_celsius{instance="dooplex",chip="platform_coretemp_0",sensor="temp1"}
fans:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: fan_speed_rpm{instance="dooplex"}
disk_root:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})
disk_root_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) / 1073741824
disk_root_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/"} / 1073741824
disk_ssd2:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/ssd_2"} / node_filesystem_size_bytes{mountpoint="/mnt/ssd_2"})
disk_ssd2_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/ssd_2"} - node_filesystem_avail_bytes{mountpoint="/mnt/ssd_2"}) / 1073741824
disk_ssd2_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/ssd_2"} / 1073741824
disk_hdd1:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/1_hdd"} / node_filesystem_size_bytes{mountpoint="/mnt/1_hdd"})
disk_hdd1_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/1_hdd"} - node_filesystem_avail_bytes{mountpoint="/mnt/1_hdd"}) / 1099511627776
disk_hdd1_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/1_hdd"} / 1099511627776
disk_hdd2:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/2_hdd"} / node_filesystem_size_bytes{mountpoint="/mnt/2_hdd"})
disk_hdd2_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/2_hdd"} - node_filesystem_avail_bytes{mountpoint="/mnt/2_hdd"}) / 1099511627776
disk_hdd2_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/2_hdd"} / 1099511627776
disk_hdd3:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/3_hdd"} / node_filesystem_size_bytes{mountpoint="/mnt/3_hdd"})
disk_hdd3_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/3_hdd"} - node_filesystem_avail_bytes{mountpoint="/mnt/3_hdd"}) / 1099511627776
disk_hdd3_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/3_hdd"} / 1099511627776
disk_hdd4:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/4_hdd"} / node_filesystem_size_bytes{mountpoint="/mnt/4_hdd"})
disk_hdd4_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/4_hdd"} - node_filesystem_avail_bytes{mountpoint="/mnt/4_hdd"}) / 1099511627776
disk_hdd4_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/4_hdd"} / 1099511627776
disk_hdd5:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/mnt/5_hdd"} / node_filesystem_size_bytes{mountpoint="/mnt/5_hdd"})
disk_hdd5_used:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: (node_filesystem_size_bytes{mountpoint="/mnt/5_hdd"} - node_filesystem_avail_bytes{mountpoint="/mnt/5_hdd"}) / 1099511627776
disk_hdd5_total:
url: ${PROMETHEUS_URL}/api/v1/query
parameters:
query: node_filesystem_size_bytes{mountpoint="/mnt/5_hdd"} / 1099511627776
template: |
<style>
.sys-stats { font-size: 0.9em; }
.top-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.top-item { text-align: center; padding: 6px 8px; background: rgba(255,255,255,0.05); border-radius: 8px; }
.top-item.wide { grid-column: span 2; }
.top-label { font-size: 0.65em; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.5px; }
.top-value { font-size: 1.05em; font-weight: 600; margin-top: 2px; }
.section-title { font-size: 0.75em; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.5px; margin: 10px 0 6px 0; }
.disk-row { display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.disk-row:last-child { border-bottom: none; }
.disk-name { font-weight: 500; width: 42px; font-size: 0.95em; flex-shrink: 0; }
/* Set the track as a reference container */
.disk-bar {
flex: 1;
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
margin: 0 10px;
overflow: hidden;
min-width: 0;
container-type: inline-size; /* <--- KEY CHANGE: Enables width measurement */
}
/* The fill acts as a "mask" or window */
.disk-fill {
height: 100%;
border-radius: 4px;
/* background: ... <--- REMOVED from here */
position: relative; /* Required for the pseudo-element positioning */
overflow: hidden; /* Clips the gradient to the current progress percentage */
box-shadow: 0 0 0 1px rgba(0,0,0,0.10) inset;
}
/* The gradient is applied here, locked to the full track width */
.disk-fill::before {
content: "";
position: absolute;
top: 0; left: 0; bottom: 0;
width: 100cqw; /* <--- KEY CHANGE: 100% of the parent .disk-bar width */
background: linear-gradient(
90deg,
#60a5fa 0%, /* blue */
#60a5fa 50%, /* keep blue until 70% of the full scale */
#a78bfa 65%, /* purple transition */
#fb7185 80%, /* pink transition */
#ef4444 92% /* red at 100% */
);
z-index: -1;
}
.disk-info { font-size: 0.9em; opacity: 0.85; text-align: right; width: 125px; flex-shrink: 0; }
</style>
{{ $hostname := .JSON.String "data.result.0.metric.nodename" }}
{{ $uptimeDays := (.Subrequest "uptime_days").JSON.Float "data.result.0.value.1" }}
{{ $uptimeHours := (.Subrequest "uptime_hours").JSON.Float "data.result.0.value.1" }}
{{ $cpu := (.Subrequest "cpu").JSON.Float "data.result.0.value.1" }}
{{ $mem := (.Subrequest "memory").JSON.Float "data.result.0.value.1" }}
{{ $temp := (.Subrequest "cpu_temp").JSON.Float "data.result.0.value.1" }}
{{ $fans := (.Subrequest "fans").JSON.Array "data.result" }}
<div class="sys-stats">
<div class="top-grid">
<div class="top-item">
<div class="top-label">Host</div>
<div class="top-value">{{ $hostname }}</div>
</div>
<div class="top-item">
<div class="top-label">CPU</div>
<div class="top-value">{{ printf "%.1f" $cpu }}%</div>
</div>
<div class="top-item">
<div class="top-label">Uptime</div>
<div class="top-value">{{ printf "%.0f" $uptimeDays }}d {{ printf "%.0f" $uptimeHours }}h</div>
</div>
<div class="top-item">
<div class="top-label">Memory</div>
<div class="top-value">{{ printf "%.1f" $mem }}%</div>
</div>
<div class="top-item">
<div class="top-label">Temp</div>
<div class="top-value">{{ printf "%.0f" $temp }}°C</div>
</div>
<div class="top-item">
<div class="top-label">Fans (RPM)</div>
<div class="top-value">{{ range $i, $fan := $fans }}{{ if $i }}/{{ end }}{{ $fan.Int "value.1" }}{{ end }}</div>
</div>
</div>
<div class="section-title">Storage</div>
{{ $rootPct := (.Subrequest "disk_root").JSON.Float "data.result.0.value.1" }}
{{ $rootUsed := (.Subrequest "disk_root_used").JSON.Float "data.result.0.value.1" }}
{{ $rootTotal := (.Subrequest "disk_root_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">Root</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $rootPct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.0f" $rootUsed }} / {{ printf "%.0f" $rootTotal }} GB ({{ printf "%.0f" $rootPct }}%)</span>
</div>
{{ $ssd2Pct := (.Subrequest "disk_ssd2").JSON.Float "data.result.0.value.1" }}
{{ $ssd2Used := (.Subrequest "disk_ssd2_used").JSON.Float "data.result.0.value.1" }}
{{ $ssd2Total := (.Subrequest "disk_ssd2_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">SSD2</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $ssd2Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.0f" $ssd2Used }} / {{ printf "%.0f" $ssd2Total }} GB ({{ printf "%.0f" $ssd2Pct }}%)</span>
</div>
{{ $hdd1Pct := (.Subrequest "disk_hdd1").JSON.Float "data.result.0.value.1" }}
{{ $hdd1Used := (.Subrequest "disk_hdd1_used").JSON.Float "data.result.0.value.1" }}
{{ $hdd1Total := (.Subrequest "disk_hdd1_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">HDD1</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $hdd1Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.1f" $hdd1Used }} / {{ printf "%.1f" $hdd1Total }} TB ({{ printf "%.0f" $hdd1Pct }}%)</span>
</div>
{{ $hdd2Pct := (.Subrequest "disk_hdd2").JSON.Float "data.result.0.value.1" }}
{{ $hdd2Used := (.Subrequest "disk_hdd2_used").JSON.Float "data.result.0.value.1" }}
{{ $hdd2Total := (.Subrequest "disk_hdd2_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">HDD2</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $hdd2Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.1f" $hdd2Used }} / {{ printf "%.1f" $hdd2Total }} TB ({{ printf "%.0f" $hdd2Pct }}%)</span>
</div>
{{ $hdd3Pct := (.Subrequest "disk_hdd3").JSON.Float "data.result.0.value.1" }}
{{ $hdd3Used := (.Subrequest "disk_hdd3_used").JSON.Float "data.result.0.value.1" }}
{{ $hdd3Total := (.Subrequest "disk_hdd3_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">HDD3</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $hdd3Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.1f" $hdd3Used }} / {{ printf "%.1f" $hdd3Total }} TB ({{ printf "%.0f" $hdd3Pct }}%)</span>
</div>
{{ $hdd4Pct := (.Subrequest "disk_hdd4").JSON.Float "data.result.0.value.1" }}
{{ $hdd4Used := (.Subrequest "disk_hdd4_used").JSON.Float "data.result.0.value.1" }}
{{ $hdd4Total := (.Subrequest "disk_hdd4_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">HDD4</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $hdd4Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.1f" $hdd4Used }} / {{ printf "%.1f" $hdd4Total }} TB ({{ printf "%.0f" $hdd4Pct }}%)</span>
</div>
{{ $hdd5Pct := (.Subrequest "disk_hdd5").JSON.Float "data.result.0.value.1" }}
{{ $hdd5Used := (.Subrequest "disk_hdd5_used").JSON.Float "data.result.0.value.1" }}
{{ $hdd5Total := (.Subrequest "disk_hdd5_total").JSON.Float "data.result.0.value.1" }}
<div class="disk-row">
<span class="disk-name">HDD5</span>
<div class="disk-bar">
<div class="disk-fill" style="width: {{ printf "%.0f" $hdd5Pct }}%"></div>
</div>
<span class="disk-info">{{ printf "%.1f" $hdd5Used }} / {{ printf "%.1f" $hdd5Total }} TB ({{ printf "%.0f" $hdd5Pct }}%)</span>
</div>
</div>
- type: bookmarks
title: Admin Tools
groups:
- links:
- title: Code-Server
url: https://code.dooplex.hu
icon: si:coder
- title: ArgoCD
url: https://argocd.dooplex.hu
icon: si:argo
- title: Gitea
url: https://gitea.dooplex.hu
icon: si:gitea
- title: Headlamp
url: https://headlamp.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/headlamp.png
- title: Authentik
url: https://authentik.dooplex.hu
icon: si:authentik
- title: Termix
url: https://termix.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/termix.png
- title: Longhorn (LAN Only)
url: http://192.168.0.209/#/dashboard
icon: https://web.dooplex.hu/static/white-icons/longhorn.png
- title: Pi-hole
url: https://pihole.dooplex.hu/admin
icon: si:pihole
- type: bookmarks
title: Useful Bookmarks
groups:
- title: ""
links:
- title: GMail
url: https://mail.google.com
icon: si:gmail
- title: Kréta
url: https://klik100566001.e-kreta.hu/Intezmeny/Faliujsag
icon: https://web.dooplex.hu/static/white-icons/kreta.png
- title: "444"
url: https://444.hu
icon: https://web.dooplex.hu/static/white-icons/444.png
- title: Telex
url: https://telex.hu
icon: https://web.dooplex.hu/static/white-icons/telex.png
- title: Hardverapró
url: https://hardverapro.hu
icon: https://web.dooplex.hu/static/white-icons/hardverapro.png
# ---------- CENTER COLUMN ----------
- size: full
widgets:
- type: split-column
max-columns: 3
widgets:
# Weather Widget (Időkép)
- type: custom-api
title: "Időkép Budapest VII."
url: "http://idokep-scraper.glance-system.svc.cluster.local:8000/api?v=2"
cache: 30s
template: |
{{ $loc := .JSON.String "location.name" }}
{{ if eq $loc "" }}{{ $loc = .JSON.String "place" }}{{ end }}
{{ $curTemp := .JSON.Float "current.temp_c" }}
{{ $curIcon := .JSON.String "current.icon_url" }}
{{ $daily := .JSON.Array "daily" }}
{{ $hourly := .JSON.Array "hourly" }}
<div class="idokep">
<div class="idokep-top">
<div class="idokep-top-left">
{{ if $curIcon }}<img class="idokep-icon" src="{{ $curIcon }}" alt="" />{{ end }}
<div class="idokep-temp">{{ printf "%.0f" $curTemp }}°C</div>
</div>
<div class="idokep-top-right">
<div class="idokep-loc">{{ $loc }}</div>
<div class="idokep-src">Forrás: <a href="{{ .JSON.String "source.url" }}" target="_blank">Időkép</a></div>
</div>
</div>
{{ if gt (len $hourly) 0 }}
<div class="idokep-hourly">
{{ range $i, $h := $hourly }}
<div class="idokep-hour">
<div class="idokep-hour-time">{{ $h.String "time" }}</div>
{{ if $h.String "icon_url" }}<img class="idokep-hour-icon" src="{{ $h.String "icon_url" }}" title="{{ $h.String "condition" }}" alt="{{ $h.String "condition" }}" />{{ end }}
<div class="idokep-hour-temp">{{ printf "%.0f" ($h.Float "temp_c") }}°</div>
</div>
{{ end }}
</div>
{{ end }}
{{ if gt (len $daily) 0 }}
<div class="idokep-daily">
{{ range $daily }}
<div class="idokep-row">
<div class="idokep-dow">
<span class="idokep-downame">{{ .String "dow" }}</span>
<span class="idokep-daynum">{{ .String "daynum" }}</span>
</div>
<div class="idokep-dayicon">
{{ if .String "icon_url" }}<img src="{{ .String "icon_url" }}" title="{{ .String "condition" }}" alt="{{ .String "condition" }}" />{{ end }}
</div>
<div class="idokep-min">{{ printf "%.0f" (.Float "tmin_c") }}°</div>
<div class="idokep-bar">
<div class="idokep-bar-track"></div>
{{/* We construct the style manually to bypass Go security sanitization */}}
<div class="idokep-bar-fill" style="
--l: {{ printf "%.1f" (.Float "c_l") }}%;
--w: {{ printf "%.1f" (.Float "c_w") }}%;
--gw: {{ printf "%.1f" (.Float "c_gw") }}%;
--ml: {{ printf "%.1f" (.Float "c_ml") }}%;
--s-wht: {{ printf "%.1f" (.Float "c_s1") }}%;
--s-blu: {{ printf "%.1f" (.Float "c_s2") }}%;
--s-pur: {{ printf "%.1f" (.Float "c_s3") }}%;
--s-pnk: {{ printf "%.1f" (.Float "c_s4") }}%;
--s-red: {{ printf "%.1f" (.Float "c_s5") }}%;
">
<div class="idokep-bar-gradient"></div>
</div>
</div>
<div class="idokep-max">{{ printf "%.0f" (.Float "tmax_c") }}°</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
# Calendar Widget
- type: calendar
first-day-of-week: monday
# To-Do List
- type: to-do
title: Tasks
# Outline Notes iframe
- type: iframe
source: https://outline.dooplex.hu/collection/dooplex-server-iTAZn04AaR/recent
height: 900
title: Documentation
# ---------- RIGHT COLUMN ----------
- size: small
widgets:
- type: custom-api
title: Meal for the Day
cache: 5m
url: http://glance-helper.glance-system.svc.cluster.local:8000/tandoor/daily
parameters:
count: 3
cooldown: 14
options:
tandoor_url: https://tandoor.dooplex.hu
template: |
{{ $tandoor := .Options.StringOr "tandoor_url" "https://tandoor.dooplex.hu" }}
{{ $items := .JSON.Array "items" }}
{{ $count := len $items }}
{{ $last := sub $count 1 }}
<style>
.mw { display:flex; flex-direction:column; gap:6px; }
.mw-meta { opacity:.65; font-size:12px; display:flex; justify-content:space-between; align-items:center; }
.mw-box { position:relative; border-radius:14px; background:rgba(255,255,255,0.04); box-shadow:0 0 0 1px rgba(255,255,255,0.06) inset; overflow:hidden; width:100%; }
.mw-box input { display:none; }
.mw-track { display:flex; transition:transform 0.3s ease; }
.mw-s { min-width:100%; width:100%; flex-shrink:0; box-sizing:border-box; }
.mw-img { height:150px; background:rgba(0,0,0,0.15); overflow:hidden; }
.mw-img img { width:100%; height:100%; object-fit:cover; display:block; }
.mw-noimg { height:150px; display:flex; align-items:center; justify-content:center; opacity:.5; font-size:12px; }
.mw-name { padding:10px 12px 4px; font-weight:700; opacity:.95; line-height:1.3; overflow-wrap:break-word; word-break:break-word; }
.mw-stats { padding:0 12px 6px; font-size:11px; opacity:.5; }
.mw-acts { padding:0 12px 10px; display:flex; gap:10px; opacity:.8; font-size:12px; }
.mw-acts a, .mw-link { text-decoration:none; color:inherit; display:block; }
.mw-p, .mw-n { position:absolute; top:75px; transform:translateY(-50%); width:26px; height:26px; border-radius:50%; background:rgba(0,0,0,0.6); color:#fff; align-items:center; justify-content:center; font-size:11px; cursor:pointer; z-index:5; display:none; }
.mw-p { left:6px; }
.mw-n { right:6px; }
.mw-p:hover, .mw-n:hover { background:rgba(0,0,0,0.85); }
.mw-dots { display:flex; justify-content:center; gap:5px; padding:3px 0 1px; }
.mw-dot { width:6px; height:6px; border-radius:50%; background:rgba(255,255,255,0.2); cursor:pointer; }
.mw-dot:hover { background:rgba(255,255,255,0.4); }
{{ range $i, $_ := $items }}
#mr{{ $i }}:checked ~ .mw-track { transform:translateX(-{{ mul $i 100 }}%); }
#mr{{ $i }}:checked ~ .mw-dots .mw-dot:nth-child({{ add $i 1 }}) { background:rgba(255,255,255,0.85); }
{{ if gt $i 0 }}.mw-box:hover #mr{{ $i }}:checked ~ .mw-p[data-t="{{ sub $i 1 }}"] { display:flex; }{{ end }}
{{ if lt $i $last }}.mw-box:hover #mr{{ $i }}:checked ~ .mw-n[data-t="{{ add $i 1 }}"] { display:flex; }{{ end }}
{{ end }}
</style>
<div class="mw">
<div class="mw-meta">
<span>Today's picks ({{ $count }} total)</span>
<a href="{{ $tandoor }}" target="_blank" rel="noreferrer">Open Tandoor</a>
</div>
{{ if lt $count 1 }}<div class="color-negative">No recipes.</div>{{ else }}
<div class="mw-box">
{{ range $i, $_ := $items }}<input type="radio" name="mr" id="mr{{ $i }}"{{ if eq $i 0 }} checked{{ end }}>{{ end }}
<div class="mw-track">
{{ range $r := $items }}{{ $img := $r.String "image" }}{{ $url := $r.String "url" }}{{ $cook := $r.String "cook_url" }}{{ $cooked := $r.Int "cooked_count" }}
<div class="mw-s">
<a class="mw-link" href="{{ $url }}" target="_blank">
<div class="mw-img">{{ if $img }}<img src="{{ $img }}" alt="">{{ else }}<div class="mw-noimg">No image</div>{{ end }}</div>
<div class="mw-name">{{ $r.String "name" }}</div>
</a>
{{ if gt $cooked 0 }}<div class="mw-stats">Cooked {{ $cooked }}× before</div>{{ end }}
<div class="mw-acts"><a href="{{ $url }}" target="_blank">Open</a> <a href="{{ $cook }}" target="_blank">Cooked today ✔</a></div>
</div>
{{ end }}
</div>
{{ if gt $count 1 }}
{{ range $i, $_ := $items }}{{ if gt $i 0 }}<label class="mw-p" for="mr{{ sub $i 1 }}" data-t="{{ sub $i 1 }}">◀</label>{{ end }}{{ if lt $i $last }}<label class="mw-n" for="mr{{ add $i 1 }}" data-t="{{ add $i 1 }}">▶</label>{{ end }}{{ end }}
<div class="mw-dots">{{ range $i, $_ := $items }}<label class="mw-dot" for="mr{{ $i }}"></label>{{ end }}</div>
{{ end }}
</div>
{{ end }}
</div>
# Calendar Events Widget (Családi only)
- type: custom-api
title: Family Calendar
cache: 5m
url: http://glance-helper.glance-system.svc.cluster.local:8000/calendar/events
parameters:
count: 10
days: 14
calendars: Családi
template: |
{{ $events := .JSON.Array "events" }}
{{ $count := len $events }}
{{ $showCount := 5 }}
{{ $hasMore := gt $count $showCount }}
<style>
.cal-widget { display: flex; flex-direction: column; gap: 6px; }
.cal-meta { opacity: .65; font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
.cal-empty { opacity: 0.5; font-size: 13px; padding: 8px 0; }
.cal-list { display: flex; flex-direction: column; gap: 4px; }
.cal-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255,255,255,0.04);
align-items: start;
}
.cal-item:hover { background: rgba(255,255,255,0.08); }
.cal-title {
font-weight: 600;
opacity: 0.95;
font-size: 13px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-time {
font-size: 12px;
opacity: 0.7;
text-align: right;
white-space: nowrap;
line-height: 1.4;
}
.cal-date { font-weight: 600; }
.cal-hour { opacity: 0.8; }
.cal-allday {
font-size: 10px;
opacity: 0.6;
text-transform: uppercase;
}
/* Collapse/expand styling */
.cal-hidden { display: none; }
.cal-toggle {
font-size: 12px;
opacity: 0.6;
cursor: pointer;
text-align: center;
padding: 6px;
border-radius: 6px;
margin-top: 4px;
}
.cal-toggle:hover { opacity: 0.9; background: rgba(255,255,255,0.05); }
#cal-expand-fam:checked ~ .cal-list .cal-extra { display: grid; }
#cal-expand-fam:checked ~ .cal-toggle-more { display: none; }
#cal-expand-fam:not(:checked) ~ .cal-toggle-less { display: none; }
</style>
<div class="cal-widget">
{{ if lt $count 1 }}
<div class="cal-empty">No events in near future</div>
{{ else }}
{{ if $hasMore }}<input type="checkbox" id="cal-expand-fam" style="display:none;">{{ end }}
<div class="cal-list">
{{ range $i, $e := $events }}
{{ $isExtra := ge $i $showCount }}
{{ $isAllDay := $e.Bool "is_all_day" }}
{{ $title := $e.String "title" }}
{{ $startDate := $e.String "start_date_relative" }}
{{ $startTime := $e.String "start_time" }}
<div class="cal-item{{ if $isExtra }} cal-extra cal-hidden{{ end }}">
<div>
<div class="cal-title">{{ $title }}</div>
</div>
<div class="cal-time">
<div class="cal-date">{{ $startDate }}</div>
{{ if $isAllDay }}
<div class="cal-allday">Whole day</div>
{{ else }}
<div class="cal-hour">{{ $startTime }}</div>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{ if $hasMore }}
<label class="cal-toggle cal-toggle-more" for="cal-expand-fam">Show more ({{ sub $count $showCount }} additional) ▼</label>
<label class="cal-toggle cal-toggle-less" for="cal-expand-fam">Show less ▲</label>
{{ end }}
{{ end }}
</div>
- type: bookmarks
title: Productivity Self-Hosted
groups:
- title: ""
links:
- title: Nextcloud
url: https://nextcloud.dooplex.hu
icon: si:nextcloud
- title: Outline
url: https://outline.dooplex.hu
icon: si:outline
- title: Paperless
url: https://paperless.dooplex.hu
icon: si:paperlessngx
- title: Vaultwarden
url: https://vaultwarden.dooplex.hu
icon: si:bitwarden
- title: Actual Budget
url: https://actualbudget.dooplex.hu
icon: si:actualbudget
- title: Tandoor
url: https://tandoor.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/tandoor.png
- title: Bookstack
url: https://bookstack.dooplex.hu
icon: si:bookstack
- type: bookmarks
title: Other Self-Hosted
groups:
- links:
- title: AdventureLog
url: https://adventures.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/adventurelog.png
- title: Wanderer
url: https://wanderer.dooplex.hu
icon: sh:wanderer
- title: Plant-it
url: https://plantit.dooplex.hu
icon: si:leaflet
- title: Workout (wger)
url: https://workout.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/wger.png
- title: Fileshare
url: https://fileshare.dooplex.hu
icon: si:files
- title: Privatebin
url: https://privatebin.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/privatebin.png
- title: Pastes (OpenGist)
url: https://paste.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/opengist.png
- title: Zipline
url: https://zipline.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/zipline.png
# ==================== MONITORING PAGE ====================
- name: Monitoring
slug: monitoring
width: wide
columns:
- size: small
widgets:
- type: bookmarks
title: Monitoring
groups:
- links:
- title: Grafana
url: https://grafana.dooplex.hu/d/adxgb7x/overview-dashboard?orgId=1&from=now-3h&to=now&timezone=browser
icon: si:grafana
- title: Uptime Kuma
url: https://uptimekuma.dooplex.hu
icon: si:uptimekuma
- title: Prometheus (LAN Only)
url: http://prometheus.home/alerts
icon: si:prometheus
# Glance Widget: Container Version Checker (Simplified)
#
# This is a more robust version that uses only Glance's documented template functions.
# Add this widget to your glance.yml under a column's widgets section.
# Place it right after the "DooPlex Server" widget.
#
# Prerequisites:
# - Deploy version-checker from version-checker.yaml
# - Wait ~5 minutes for initial version checks to complete
- type: custom-api
title: Container Versions
cache: 5m
url: http://glance-helper.glance-system.svc.cluster.local:8000/versions
template: |
<style>
.ver-widget { font-size: 0.9em; }
.ver-summary {
display: flex;
gap: 12px;
margin-bottom: 12px;
padding: 8px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
}
.ver-stat {
flex: 1;
text-align: center;
padding: 4px;
}
.ver-label {
font-size: 0.7em;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ver-value {
font-size: 1.4em;
font-weight: 600;
margin-top: 2px;
}
.ver-ok { color: #4ade80; }
.ver-warn { color: #fbbf24; }
.ver-info { color: #5ac8d8; }
.ver-list {
max-height: 220px;
overflow-y: auto;
}
.ver-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 4px;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.08);
font-size: 0.82em;
}
.ver-row:last-child { border-bottom: none; }
.ver-img {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.9;
}
.ver-cur {
color: #f87171;
font-family: monospace;
font-size: 0.95em;
}
.ver-arr {
color: rgba(255,255,255,0.3);
padding: 0 2px;
}
.ver-lat {
color: #4ade80;
font-family: monospace;
font-size: 0.95em;
}
.ver-allok {
text-align: center;
padding: 16px;
color: #4ade80;
opacity: 0.85;
}
.ver-nodata {
text-align: center;
padding: 16px;
opacity: 0.5;
font-size: 0.9em;
}
</style>
{{ $upToDate := .JSON.Float "summary.up_to_date" }}
{{ $outdated := .JSON.Float "summary.outdated" }}
{{ $total := .JSON.Float "summary.total" }}
{{ $updates := .JSON.Array "outdated" }}
<div class="ver-widget">
{{ if gt $total 0.0 }}
<div class="ver-summary">
<div class="ver-stat">
<div class="ver-label">Current</div>
<div class="ver-value ver-ok">{{ printf "%.0f" $upToDate }}</div>
</div>
<div class="ver-stat">
<div class="ver-label">Updates</div>
<div class="ver-value ver-warn">{{ printf "%.0f" $outdated }}</div>
</div>
<div class="ver-stat">
<div class="ver-label">Total</div>
<div class="ver-value ver-info">{{ printf "%.0f" $total }}</div>
</div>
</div>
{{ if gt $outdated 0.0 }}
<div class="ver-list">
{{ range $updates }}
<div class="ver-row" title="{{ .String "image" }}">
<span class="ver-img">{{ trimPrefix "ghcr.io/" (trimPrefix "docker.io/" (trimPrefix "lscr.io/" (trimPrefix "quay.io/" (.String "image")))) }}</span>
<span class="ver-cur">{{ .String "current_version" }}</span>
<span class="ver-arr">→</span>
<span class="ver-lat">{{ .String "latest_version" }}</span>
</div>
{{ end }}
</div>
{{ else }}
<div class="ver-allok">✓ All images up to date!</div>
{{ end }}
{{ else }}
<div class="ver-nodata">Waiting for version data...<br><small>Check glance-helper logs</small></div>
{{ end }}
</div>
- size: full
widgets:
# Grafana
- type: iframe
source: https://grafana.dooplex.hu/d/nazwb7z/overview-dashboard-for-glance?kiosk
height: 1140
title: Grafana Overview Dashboard
- size: small
widgets:
- type: custom-api
title: Uptime Kuma
title-url: ${UPTIME_KUMA_PUBLIC_URL}
url: ${UPTIME_KUMA_URL}/api/status-page/${UPTIME_KUMA_STATUS_SLUG}
subrequests:
heartbeats:
url: ${UPTIME_KUMA_URL}/api/status-page/heartbeat/${UPTIME_KUMA_STATUS_SLUG}
cache: 10m
template: |
{{ $hb := .Subrequest "heartbeats" }}
{{ if not (.JSON.Exists "publicGroupList") }}
<p class="color-negative">Error reading response</p>
{{ else if eq (len (.JSON.Array "publicGroupList")) 0 }}
<p>No monitors found</p>
{{ else }}
<ul class="dynamic-columns list-gap-8">
{{ range .JSON.Array "publicGroupList" }}
{{ range .Array "monitorList" }}
{{ $id := .String "id" }}
{{ $hbArray := $hb.JSON.Array (print "heartbeatList." $id) }}
<div class="flex items-center gap-12">
<a class="size-title-dynamic color-highlight text-truncate block grow" href="${UPTIME_KUMA_URL}/dashboard/{{ $id }}"
target="_blank" rel="noreferrer">
{{ .String "name" }} </a>
{{ if gt (len $hbArray) 0 }}
{{ $latest := index $hbArray (sub (len $hbArray) 1) }}
{{ if eq ($latest.Int "status") 1 }}
<div>{{ $latest.Int "ping" }}ms</div>
<div class="monitor-site-status-icon-compact" title="OK">
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z"
clip-rule="evenodd"></path>
</svg>
</div>
{{ else }}
<div><span class="color-negative">DOWN</span></div>
<div class="monitor-site-status-icon-compact" title="{{ if $latest.Exists "msg" }}{{ $latest.String "msg" }}{{ else
}}Error{{ end }}">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"></path>
</svg>
</div>
{{ end }}
{{ else }}
<div><span class="color-negative">No data</span></div>
<div class="monitor-site-status-icon-compact" title="No data available">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M10 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-2a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm-.75-8a.75.75 0 0 1 1.5 0v3a.75.75 0 0 1-1.5 0V8zm.75 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
</ul>
{{ end }}
# ==================== MEDIA PAGE ====================
- name: Media
slug: media
width: wide
columns:
- size: small
widgets:
- type: bookmarks
title: Entertainment
groups:
- links:
- title: Plex
url: https://plex.dooplex.hu
icon: si:plex
- title: Immich (Photos)
url: https://photos.dooplex.hu
icon: si:immich
- title: AudioBookshelf
url: https://audiobookshelf.dooplex.hu
icon: si:audiobookshelf
- title: Calibre-Web (eBooks)
url: https://books.dooplex.hu
icon: si:calibreweb
- title: Arcade (Retro Games)
url: https://arcade.dooplex.hu
icon: si:retroarch
- title: Crafty Controller
url: https://crafty.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/minecraftcreeper.png
- title: YouTube
url: https://www.youtube.com
icon: si:youtube
- title: Spotify
url: https://open.spotify.com/
icon: si:spotify
- type: bookmarks
title: Media Management
groups:
- links:
- title: Sonarr (TV Shows)
url: https://sonarr.dooplex.hu
icon: si:sonarr
- title: Radarr (Movies)
url: https://radarr.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/radarr.png
- title: RadarrKids
url: https://radarrkids.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/radarrkids.png
- title: Prowlarr (Indexers)
url: https://prowlarr.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/prowlarr.png
- title: Seerr (Requests)
url: https://seerr.dooplex.hu
icon: https://web.dooplex.hu/static/white-icons/seerr.png
- type: custom-api
title: Crafty - Minecraft Server
cache: 5s
options:
base-url: ${CRAFTY_URL}
api-key: ${CRAFTY_API_TOKEN}
server-id: ${CRAFTY_SERVER_ID}
display-MOTD: true
allow-insecure: true
template: |
{{/* Required config options */}}
{{ $baseURL := .Options.StringOr "base-url" "" }}
{{ $apiKey := .Options.StringOr "api-key" "" }}
{{ $serverID := .Options.StringOr "server-id" "" }}
{{/* Optional config options */}}
{{ $displayMOTD := .Options.BoolOr "display-MOTD" true }}
{{ $serverStats := newRequest (print $baseURL "/api/v2/servers/" $serverID "/stats")
| withHeader "Authorization" (print "Bearer " $apiKey)
| withHeader "Accept" "application/json"
| getResponse }}
{{ $is_running := $serverStats.JSON.Bool "data.running" }}
{{ $online_players := $serverStats.JSON.Int "data.online" | formatNumber }}
{{ $max_players := $serverStats.JSON.Int "data.max" | formatNumber }}
{{ $name := $serverStats.JSON.String "data.world_name" }}
{{ $size := $serverStats.JSON.String "data.world_size" }}
{{ $version := $serverStats.JSON.String "data.version" }}
{{ $icon := $serverStats.JSON.String "data.icon" }}
{{ $server_ip := $serverStats.JSON.String "data.server_id.server_ip" }}
{{ $server_port := $serverStats.JSON.String "data.server_id.server_port" }}
{{ $motd := $serverStats.JSON.String "data.desc" }}
{{ $server_addr := "" }}
{{ if and ($is_running) (eq $server_ip "127.0.0.1") }}
{{ $server_addr = printf "%s:%s" (replaceMatches "https?://" "" $baseURL) $server_port }}
{{ else if $is_running }}
{{ $server_addr = printf "%s:%s" $server_ip $server_port }}
{{ end }}
{{ $starting := false }}
{{ if and ($is_running) (eq $max_players "0") (eq $version "False") }}
{{ $starting = true }}
{{ end }}
<!-- I couldn't find documentation describing the "waiting_start" state or the other booleans below. Implementation might not be correct. -->
{{ $updating := $serverStats.JSON.Bool "data.updating" }}
{{ $importing := $serverStats.JSON.Bool "data.importing" }}
{{ $crashed := $serverStats.JSON.Bool "data.crashed" }}
<div style="display:flex; align-items:center; gap:12px;">
<!-- Server Icon -->
<div style="width:40px; height:40px; flex-shrink:0; border-radius:4px; display:flex; justify-content:center; align-items:center; overflow:hidden;">
{{ if eq $icon "" }}
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/png/minecraft.png" style="width:100%; height:100%; object-fit:contain;" alt="Server icon">
{{ else }}
<img src="data:image/png;base64, {{ $icon }}" style="width:100%; height:100%; object-fit:contain;" alt="Server icon">
{{ end }}
</div>
<!-- Right side: Info -->
<div style="display:flex; flex-direction:column;">
<!-- First row: Server Name + IP -->
<div style="display:flex; align-items:center; gap:6px;">
<span class="size-h4 block text-truncate color-primary">
{{ $name }}
</span>
{{ if and ($is_running) (not $starting) (not (eq $server_addr "")) }}
<div style="font-size:0.9em; color:var(--color-secondary);">
<span class="size-h6 color-secondary">
- {{ $server_addr }}
</span>
</div>
{{ end }}
</div>
<!-- Second row: MOTD & Stats if server is running, otherwise show status msg ... -->
{{ if and ($is_running) (not $starting) }}
{{ if and (not (eq $motd "")) ($displayMOTD) }}
<div style="font-size:0.9em; color:var(--color-secondary);">
{{ replaceMatches "§." "" $motd }}
</div>
{{ end }}
<div style="font-size:0.9em; color:var(--color-secondary);">
{{ $version }} - {{ $online_players }}/{{ $max_players }} players - {{ $size }}
</div>
<!-- lots of assumptions about the boolean states meanings from crafty api.. -->
{{ else if $starting }}
<div style="font-size:0.9em; color:var(--color-secondary);">Server is starting up..</div>
{{ else if $importing }}
<div style="font-size:0.9em; color:var(--color-secondary);">Server is being imported..</div>
{{ else if $updating }}
<div style="font-size:0.9em; color:var(--color-secondary);">Server is being updated..</div>
{{ else if $crashed }}
<div style="font-size:0.9em; color:var(--color-secondary);">Server has crashed!</div>
{{ else }}
<div style="font-size:0.9em; color:var(--color-secondary);">Server is offline</div>
{{ end }}
</div>
</div>
- type: custom-api
title: RomM
cache: 1d
url: http://${ROMM_URL}/api/stats
headers:
Accept: application/json
template: |
{{ $bytes := .JSON.Int "TOTAL_FILESIZE_BYTES" | toFloat }}
{{ $tb := div $bytes 1099511627776 }}
{{ $gb := div $bytes 1073741824 | toInt }}
<div class="flex justify-between text-center">
<div>
<div class="color-highlight size-h3">{{ .JSON.Int "PLATFORMS" | formatNumber }}</div>
<div class="size-h6">PLATFORMS</div>
</div>
<div>
<div class="color-highlight size-h3">{{ .JSON.Int "ROMS" | formatNumber }}</div>
<div class="size-h6">ROMS</div>
</div>
<div>
<div class="color-highlight size-h3">
{{ if ge $tb 1.0 }}
{{ printf "%.2f" $tb }}TB
{{ else }}
{{ $gb }}GB
{{ end }}
</div>
<div class="size-h6">FILESIZE</div>
</div>
</div>
- type: custom-api
title: Steam Specials
cache: 12h
url: https://store.steampowered.com/api/featuredcategories?cc=us
template: |
<ul class="list list-gap-10 collapsible-container" data-collapse-after="5">
{{ range .JSON.Array "specials.items" }}
{{ $header := .String "header_image" }}
{{ $urlPrefix := "https://store.steampowered.com/sub/" }}
{{ if findMatch "/steam/apps/" $header }}
{{ $urlPrefix = "https://store.steampowered.com/app/" }}
{{ end }}
<li>
<a class="size-h4 color-highlight block text-truncate" href="{{ $urlPrefix }}{{ .Int "id" }}/">{{ .String "name" }}</a>
<ul class="list-horizontal-text">
<li>{{ .Int "final_price" | toFloat | mul 0.01 | printf "$%.2f" }}</li>
{{ $discount := .Int "discount_percent" }}
<li{{ if ge $discount 40 }} class="color-positive"{{ end }}>{{ $discount }}% off</li>
</ul>
</li>
{{ end }}
</ul>
- size: full
widgets:
# YouTube Videos
- type: videos
title: YouTube - Gaming
channels:
- UC4Xj6emHTXnKHUq8btUuN6A #Retromation
- UC5ib5bTflXtyIkoF_l7OCHw #Olexa
- UCfWybrB-Faa30sXUE6eqMIQ #Zarfen the Loot Goblin
- UCto7D1L-MiRoOziCXK9uT5Q #Let's Game It Out
limit: 12
collapse-after: 6
- type: videos
title: YouTube - Tech
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw #LinusTechTips
- UCdngmbVKX1Tgre699-XLlUA #Techworld with Nana
- UCJa14zeVf8p6clixTOIOVyQ #Jakkuh (Jake)
- UC-2YHgc363EdcusLIBbgxzg #JoeScott
- UCY1kMZp36IQSyNx_9h4mpCg #Mark Rober
- UC7IcJI8PUf5Z3zKxnZvTBog #The School of Life
limit: 12
collapse-after: 6
- type: videos
title: YouTube - Other
channels:
- UC9qpYwK7N9EB0-SECANa23g #Jólvanezígy
- UCEFpEvuosfPGlV1VyUF6QOA #Partizán
- UC_fAjvoGnqySaNM6DRfFiCA #Pottyondi
- UCM-1sd-cXSuCsfWp8QMY_OQ #Telex.hu
- UCdPhEv5wiw2uK6J0_wkc-RA #Bűnvadászok
- UCQAeX1_gw45xb3Cg-OwEFcw #Friderikusz
- UCW5OrUZ4SeUYkUg1XqcjFYA #GeoWizard
limit: 12
collapse-after: 6
# Reddit
- type: group
title: Reddit
widgets:
- type: reddit
subreddit: hungary
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- type: reddit
subreddit: homeserver
show-thumbnails: true
- type: reddit
subreddit: homelab
show-thumbnails: true
- type: reddit
subreddit: kubernetes
show-thumbnails: true
- type: reddit
subreddit: linux
show-thumbnails: true
- type: reddit
subreddit: sysadmin
show-thumbnails: true
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: futurology
show-thumbnails: true
- size: small
widgets:
# RSS Feeds - Add your favorite feeds here
- type: rss
title: News & Feeds
limit: 15
collapse-after: 15
feeds:
- url: https://telex.hu/rss
title: telex.hu
limit: 3
- url: https://444.hu/feed
title: 444.hu
limit: 3
- url: https://444.hu/feed
title: 444.hu
limit: 3
- url: https://hvg.hu/rss
title: hvg.hu
limit: 3
# ==================== NEXTCLOUD PAGE ====================
- name: NextCloud
slug: nextcloud
width: wide
columns:
- size: full
widgets:
# Nextcloud iframe
- type: iframe
css-class: iframe-no-tint
source: https://nextcloud.dooplex.hu/apps/files/files
height: 1200
title: NextCloud
custom.css: |
/* =========================================================================
WALLPAPER VISIBLE
========================================================================= */
html, body { height: 100%; }
html {
background: url("https://web.dooplex.hu/static/wallpaper-2.jpg") center / cover no-repeat fixed !important;
}
/* Glance containers that tend to paint over the wallpaper */
body,
.page,
#page-content,
.page-content,
.content-bounds,
.page-columns,
.page-column {
background: transparent !important;
}
/* Optional readability veil (Homepage-like) */
body::before {
content: "";
position: fixed;
inset: 0;
background: rgba(20, 10, 30, 0.25);
pointer-events: none;
z-index: 0;
}
body > * { position: relative; z-index: 1; }
/* =========================================================================
ROOT VARIABLES OVERRIDE
These override Glance's default theme colors at the CSS variable level
========================================================================= */
:root {
/* Primary color - affects many built-in elements */
--color-primary: hsl(190, 70%, 60%) !important;
/* These control various UI elements */
--color-text-highlight: #5ac8d8 !important;
--color-text-accent: #5ac8d8 !important;
}
/* =========================================================================
GLOBAL LINK COLORS
Affects all <a> tags site-wide (bookmarks, reddit links, video titles, etc.)
========================================================================= */
a {
color: #5ac8d8 !important; /* CYAN - main link color */
}
a:hover {
color: #7ed9e6 !important; /* LIGHTER CYAN - hover state */
}
/* Visited links - slightly muted */
a:visited {
color: #4ab8c8 !important; /* SLIGHTLY DARKER CYAN */
}
/* =========================================================================
HEADER / NAVIGATION
The top bar with Home, Media, NextCloud tabs
========================================================================= */
/* Push nav items to bottom of header and align properly */
.header.flex {
align-items: flex-end !important;
}
.header.flex > .nav.flex {
height: 100% !important;
align-items: flex-end !important;
padding-bottom: 0 !important; /* Remove extra padding */
}
/* Nav item text styling */
.header .nav .nav-item,
.header.flex > .nav.flex > .nav-item {
color: #5ac8d8 !important; /* CYAN - nav text color */
font-size: 20px !important;
line-height: 1 !important;
padding: 8px 14px 12px 14px !important; /* top right bottom left */
letter-spacing: 0.3px !important;
text-transform: uppercase !important;
font-weight: 500 !important;
display: flex !important;
align-items: flex-end !important;
height: auto !important; /* Let it size naturally */
}
/* Nav item hover */
.header .nav .nav-item:hover {
color: #7ed9e6 !important; /* LIGHTER CYAN on hover */
}
/* Active tab underline - position closer to text */
.header .nav .nav-item-current::after,
.header .nav .nav-item[aria-current="page"]::after {
bottom: 4px !important; /* Closer to text */
background-color: #5ac8d8 !important; /* CYAN underline */
}
.header {
min-height: 80px !important;
align-items: flex-end !important;
}
/* This matches your DOM: <div class="logo"> <img ...> */
.logo img {
max-height: 100px !important;
height: auto !important;
width: auto !important;
object-fit: contain !important;
}
/* =========================================================================
HEADER BAR - Transparent background
========================================================================= */
.header-container,
.header-container.content-bounds,
.header,
.header.flex,
div.header-container {
background: transparent !important;
background-color: transparent !important;
}
/* =========================================================================
WIDGET TITLES
The "WEATHER", "NEWS & FEEDS", etc. headers on each widget
========================================================================= */
/* The header container */
.widget-header {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
min-height: 2.2em !important;
padding: 0.5em 0 0.5em 12px !important; /* top right bottom LEFT */
box-sizing: border-box !important;
}
/* The h2 title text inside */
.widget-header h2,
.widget-header .uppercase,
h2.uppercase {
color: #5ac8d8 !important;
font-weight: 600 !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1 !important;
}
/* =========================================================================
REMOVE DEFAULT BORDER & ADD CYAN LINE between header and content
========================================================================= */
/* Nuclear option - remove ALL borders from widget-content */
.widget-content,
.widget-content:not(.widget-content-frameless),
.widget-content-frame,
.widget-content:first-of-type,
.widget > .widget-content,
[class*="widget-content"] {
border: 0 !important;
border-top: 0 !important;
border-width: 0 !important;
border-style: none !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
--color-widget-content-border: transparent !important;
background-color: rgba(20, 50, 70, 0.35) !important; /* DARK BLUE, semi-transparent */
}
/* Add cyan line via pseudo-element on header bottom */
.widget-header::after {
content: "" !important;
display: block !important;
position: absolute !important;
bottom: 0 !important;
left: 12px !important;
right: 12px !important;
height: 1px !important;
background: rgba(90, 200, 216, 0.4) !important; /* CYAN line */
}
/* Ensure header is positioned for the pseudo-element */
.widget-header {
position: relative !important;
}
/* =========================================================================
WIDGET BACKGROUNDS
Semi-transparent backgrounds for widget containers
========================================================================= */
/* Standard widgets (calendar, weather, to-do, RSS, etc.) */
.widget,
.widget-type-calendar,
.widget-type-weather,
.widget-type-to-do,
.widget-type-rss,
.widget-type-videos,
.widget-type-reddit,
.widget-type-bookmarks {
background-color: rgba(20, 50, 70, 0.35) !important; /* DARK BLUE, semi-transparent */
}
/* RSS feed items */
.rss-item,
.feed-item {
background-color: rgba(45, 126, 136, 0.25) !important; /* TEAL tint */
border-radius: 6px !important;
margin-bottom: 4px !important;
}
/* Video items */
.video-item,
.videos-item {
background-color: rgba(45, 126, 136, 0.25) !important; /* TEAL tint */
border-radius: 8px !important;
}
/* =========================================================================
WIDGET OVERLAYS
========================================================================= */
.widget.widget-type-iframe {
position: relative !important;
overflow: hidden !important;
border-radius: 12px !important;
}
.widget.widget-type-iframe iframe {
border-radius: 12px !important;
width: 100% !important;
border: 0 !important;
filter: sepia(0.25) saturate(1) hue-rotate(0deg) brightness(1.05) !important;
position: relative !important;
z-index: 1 !important;
}
/* Overlay ON TOP of iframe (you cant style inside cross-origin iframes) */
.widget.widget-type-iframe::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
border-radius: 12px;
background: rgba(45, 181, 230, 0.12);
}
/* iFrames: slightly transparent + boosted contrast/brightness */
.widget.widget-type-iframe iframe {
opacity: 0.7 !important;
filter: contrast(1.15) brightness(1.3) !important;
border-radius: 12px !important;
width: 100% !important;
border: 0 !important;
position: relative !important;
z-index: 1 !important;
}
/* =========================================================================
CONTAINER VERSIONS WIDGET HEIGHT INCREASE
========================================================================= */
.widget.widget-type-custom-api:has(.ver-widget) .ver-list {
max-height: 855px !important;
}
.widget.widget-type-custom-api:has(.ver-widget) {
min-height: 975px !important;
}
/* =========================================================================
WIDGET ROUNDED CORNERS
========================================================================= */
/* The parent widget container - round ALL corners and clip children */
.widget {
border-radius: 12px !important;
overflow: hidden !important; /* This clips the children to the rounded shape */
}
/* Header - round only TOP corners */
.widget-header {
border-top-left-radius: 12px !important;
border-top-right-radius: 12px !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
/* Content - round only BOTTOM corners */
.widget-content,
.widget-content:not(.widget-content-frameless),
.widget-content-frame {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-bottom-left-radius: 12px !important;
border-bottom-right-radius: 12px !important;
}
/* For widgets without headers (content only), round all corners */
.widget:not(:has(.widget-header)) .widget-content {
border-radius: 12px !important;
}
/* =========================================================================
BOOKMARK STYLING
The link cards in bookmark widgets
========================================================================= */
.bookmark-link {
background-color: rgba(30, 70, 90, 0.6) !important; /* DARK BLUE-TEAL */
border-radius: 8px !important;
transition: background-color 0.2s ease !important;
}
.bookmark-link:hover {
background-color: rgba(45, 100, 120, 0.8) !important; /* LIGHTER on hover */
}
/* =========================================================================
REDDIT WIDGET SPECIFIC
Subreddit tabs and content
========================================================================= */
/* Subreddit tabs (R/HUNGARY, R/SELFHOSTED, etc.) */
.reddit-subreddit-tabs .tab,
.subreddit-tabs button,
.subreddit-tab,
[class*="subreddit"] button,
.widget-type-reddit button {
color: #5ac8d8 !important; /* CYAN */
}
.reddit-subreddit-tabs .tab:hover,
.subreddit-tabs button:hover,
[class*="subreddit"] button:hover {
color: #7ed9e6 !important; /* LIGHTER CYAN on hover */
}
/* Active subreddit tab */
.reddit-subreddit-tabs .tab.active,
.subreddit-tabs button.active,
.subreddit-tab.active,
[class*="subreddit"] button[aria-selected="true"] {
color: #7ed9e6 !important;
border-color: #5ac8d8 !important;
}
/* =========================================================================
VIDEO WIDGET SPECIFIC
YouTube video titles and channel names
========================================================================= */
.video-title,
.videos-item-title,
.video-channel,
.videos-item-channel {
color: #5ac8d8 !important;
}
/* =========================================================================
BACKGROUNDS - TRANSPARENT
Keep page backgrounds transparent to show the space background
========================================================================= */
.content-bounds,
.body-content,
.page,
#page-content,
.page-column,
.page-columns {
background: transparent !important;
}
/* =========================================================================
SCROLLBAR STYLING
Custom scrollbar colors
========================================================================= */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(30, 60, 90, 0.4); /* DARK BLUE track */
}
::-webkit-scrollbar-thumb {
background: rgba(90, 200, 216, 0.5); /* CYAN thumb */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(90, 200, 216, 0.7); /* BRIGHTER CYAN on hover */
}
/* =========================================================================
EXPAND/COLLAPSE BUTTONS
"Show more" buttons in RSS, Reddit widgets
========================================================================= */
.expand-toggle-button,
.expand-toggle-button.container-expanded,
.widget-type-rss .expand-toggle-button {
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
border: 0 !important;
}
.widget-type-rss .expand-toggle-button {
margin-top: 8px !important;
padding: 10px 12px !important;
border-top: 1px solid rgba(90, 200, 216, 0.2) !important; /* CYAN tinted border */
color: rgba(255,255,255,0.75) !important;
}
.expand-toggle-button:hover,
.widget-type-rss .expand-toggle-button:hover {
color: #5ac8d8 !important; /* CYAN on hover */
}
/* Remove pseudo-element backgrounds */
.expand-toggle-button::before,
.expand-toggle-button::after,
.expand-toggle-button-icon::before,
.expand-toggle-button-icon::after,
.widget-type-rss .expand-toggle-button::before,
.widget-type-rss .expand-toggle-button::after {
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
border: 0 !important;
}
.expand-toggle-button.container-expanded {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* =========================================================================
IFRAME STYLING
Remove tint/overlay from embedded iframes
========================================================================= */
.widget.iframe-no-tint iframe {
filter: none !important;
}
.widget.iframe-no-tint::after {
content: none !important;
display: none !important;
}
/* =========================================================================
ADDITIONAL ELEMENTS
Various UI elements that might need color overrides
========================================================================= */
/* Monitor widget status indicators */
.monitor-site-status {
color: #5ac8d8 !important;
}
/* Group widget tabs */
.group-tabs button,
.tabs button {
color: #5ac8d8 !important;
}
.group-tabs button:hover,
.tabs button:hover {
color: #7ed9e6 !important;
}
.group-tabs button.active,
.tabs button.active,
.group-tabs button[aria-selected="true"],
.tabs button[aria-selected="true"] {
color: #7ed9e6 !important;
border-color: #5ac8d8 !important;
}
/* =========================================================================
Időkép custom-api widget
========================================================================= */
.idokep { display: flex; flex-direction: column; gap: 10px; }
.idokep-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.idokep-top-left { display: flex; align-items: center; gap: 10px; }
.idokep-icon { width: 42px; height: 42px; opacity: 0.95; }
.idokep-temp { font-size: 42px; font-weight: 700; letter-spacing: 0.5px; line-height: 1; }
.idokep-top-right { text-align: right; }
.idokep-loc { opacity: 0.85; font-weight: 600; }
.idokep-src { opacity: 0.6; font-size: 12px; margin-top: 2px; }
.idokep-src a { opacity: 0.9; }
.idokep-hourly { display: flex; gap: 10px; padding-top: 4px; }
.idokep-hour { width: 54px; display: flex; flex-direction: column; align-items: center; gap: 6px; opacity: 0.9; }
.idokep-hour-time { font-size: 12px; opacity: 0.65; }
.idokep-hour-icon { width: 26px; height: 26px; }
.idokep-hour-temp { font-weight: 700; }
.idokep-muted { opacity: 0.6; font-size: 12px; padding: 4px 0; }
.idokep-daily { display: flex; flex-direction: column; gap: 8px; margin-top: 2px; }
.idokep-row {
display: grid;
grid-template-columns: 44px 26px 36px 1fr 36px; /* day+date, icon, min, bar, max */
gap: 10px;
align-items: center;
}
.idokep-dow { opacity: 0.7; font-weight: 700; }
.idokep-dayicon img { width: 22px; height: 22px; opacity: 0.95; }
.idokep-min, .idokep-max { text-align: right; font-weight: 700; opacity: 0.8; }
.idokep-dow {
display: grid;
grid-template-columns: 22px 1fr; /* dow then daynum */
column-gap: 6px;
align-items: center;
opacity: 0.8;
font-weight: 700;
}
.idokep-daynum {
text-align: right;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
.idokep-bar {
position: relative;
height: 10px;
}
.idokep-bar-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255,255,255,0.10);
}
.idokep-bar-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset;
/* Position controlled by Python variables */
left: var(--l, 0%);
width: var(--w, 0%);
}
/* This element holds the gradient */
.idokep-bar-gradient {
position: absolute;
top: 0;
bottom: 0;
/* Compensation geometry controlled by Python variables */
width: var(--gw, 100%);
margin-left: var(--ml, 0%);
/* The Dynamic Gradient */
background: linear-gradient(90deg,
#ffffff var(--s-wht),
#60a5fa var(--s-blu),
#a78bfa var(--s-pur),
#fb7185 var(--s-pnk),
#ef4444 var(--s-red)
);
}
/* =========================================================================
Forcing transparency on no-tint iframes
========================================================================= */
/* Force notes iframe to be transparent */
.widget.iframe-no-tint iframe {
background: transparent !important;
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: glance-kisfenyo
namespace: glance-system
labels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
app.kubernetes.io/version: "v0.8.4"
annotations:
reloader.stakater.com/auto: "true"
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
template:
metadata:
labels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
app.kubernetes.io/version: "v0.8.4"
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
initContainers:
- name: build-bookmarks-index
image: mikefarah/yq:4.50.1
securityContext:
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
command: ["/bin/sh", "-c"]
args:
- |
set -eux
which yq
yq --version
mkdir -p /app/assets
yq eval -o=json '
[ .pages[] as $p
| $p.columns[]? as $c
| $c.widgets[]? as $w
| select($w.type == "bookmarks")
| $w.groups[]? as $g
| $g.links[]?
| select(.url != null and .url != "")
| {
"title": (.title // .url),
"url": .url,
"page": ($p.name // ""),
"widget": ($w.title // ""),
"group": ($g.title // "")
}
] | unique_by(.url)
' /config/glance.yml > /app/assets/bookmarks.json
echo "Bookmarks indexed: $(yq eval -r 'length' /app/assets/bookmarks.json)"
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: assets
mountPath: /app/assets
containers:
- name: glance
image: glanceapp/glance:v0.8.4
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: "Europe/Budapest"
- name: PROMETHEUS_URL
value: "http://prometheus.mon-system.svc.cluster.local:9090"
- name: CRAFTY_URL
value: "https://crafty.dooplex.hu"
- name: CRAFTY_API_TOKEN
value: "newtoken"
- name: CRAFTY_SERVER_ID
value: "837c7378-7a76-46f7-b8ea-6f35c0a314f4"
- name: ROMM_URL
value: "arcade.dooplex.hu"
- name: UPTIME_KUMA_PUBLIC_URL
value: "http://uptimekuma.dooplex.hu"
- name: UPTIME_KUMA_URL
value: "http://uptimekuma.uptimekuma-system.svc.cluster.local:3001"
- name: UPTIME_KUMA_STATUS_SLUG
value: "homepage"
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: assets
mountPath: /app/assets
- name: config
mountPath: /app/config/glance.yml
subPath: glance.yml
- name: config
mountPath: /app/assets/custom.css
subPath: custom.css
volumes:
- name: config
configMap:
name: glance-config-kisfenyo
- name: assets
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: glance-kisfenyo
namespace: glance-system
labels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
spec:
type: ClusterIP
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
---
# Ingress WITH Authentik proxy authentication
# Update the auth-url annotation with your actual outpost service name after creating in Authentik
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: glance-kisfenyo
namespace: glance-system
labels:
app.kubernetes.io/name: glance-kisfenyo
app.kubernetes.io/instance: glance-kisfenyo
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
external-dns.alpha.kubernetes.io/hostname: kisfenyo.dooplex.hu
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
nginx.ingress.kubernetes.io/proxy-busy-buffers-size: "32k"
# Authentik Forward Auth annotations
# TODO: Update 'glance-outpost' with your actual outpost name after creating in Authentik
nginx.ingress.kubernetes.io/auth-url: http://ak-outpost-glance-outpost.auth-system.svc.cluster.local:9000/outpost.goauthentik.io/auth/nginx
nginx.ingress.kubernetes.io/auth-signin: https://kisfenyo.dooplex.hu/outpost.goauthentik.io/start?rd=$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header X-Forwarded-Host $http_host;
nginx.ingress.kubernetes.io/configuration-snippet: |
set $geo_allowed 0;
if ($remote_addr ~ "^192\.168\.") { set $geo_allowed 1; }
if ($remote_addr ~ "^10\.") { set $geo_allowed 1; }
if ($geoip2_country_code = "HU") { set $geo_allowed 1; }
if ($geo_allowed = 0) {
return 403 "Access restricted to Hungary";
}
spec:
ingressClassName: nginx-internal
rules:
- host: kisfenyo.dooplex.hu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: glance-kisfenyo
port:
number: 8080
tls:
- hosts:
- kisfenyo.dooplex.hu
secretName: glance-kisfenyo-tls
---