mirror of
https://github.com/Xevion/glance.git
synced 2026-01-31 10:24:27 -06:00
Merge branch 'release/v0.7.0' into default-expand-mobile-navigation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -1,27 +1,6 @@
|
||||
import { setupPopovers } from './popover.js';
|
||||
|
||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
import { setupMasonries } from './masonry.js';
|
||||
import { throttledDebounce, isElementVisible } from './utils.js';
|
||||
|
||||
async function fetchPageContent(pageData) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
@@ -427,7 +406,7 @@ function setupCollapsibleGrids() {
|
||||
|
||||
const button = attachExpandToggleButton(gridElement);
|
||||
|
||||
let cardsPerRow = 2;
|
||||
let cardsPerRow;
|
||||
|
||||
const resolveCollapsibleItems = () => {
|
||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||
@@ -457,12 +436,11 @@ function setupCollapsibleGrids() {
|
||||
}
|
||||
};
|
||||
|
||||
afterContentReady(() => {
|
||||
cardsPerRow = getCardsPerRow();
|
||||
resolveCollapsibleItems();
|
||||
});
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!isElementVisible(gridElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
const newCardsPerRow = getCardsPerRow();
|
||||
|
||||
if (cardsPerRow == newCardsPerRow) {
|
||||
@@ -472,6 +450,8 @@ function setupCollapsibleGrids() {
|
||||
cardsPerRow = newCardsPerRow;
|
||||
resolveCollapsibleItems();
|
||||
});
|
||||
|
||||
afterContentReady(() => observer.observe(gridElement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,9 +503,34 @@ function timeInZone(now, zone) {
|
||||
timeInZone = now
|
||||
}
|
||||
|
||||
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
|
||||
|
||||
return { time: timeInZone, diffInHours: diffInHours };
|
||||
return { time: timeInZone, diffInMinutes: diffInMinutes };
|
||||
}
|
||||
|
||||
function zoneDiffText(diffInMinutes) {
|
||||
if (diffInMinutes == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sign = diffInMinutes < 0 ? "-" : "+";
|
||||
const signText = diffInMinutes < 0 ? "behind" : "ahead";
|
||||
|
||||
diffInMinutes = Math.abs(diffInMinutes);
|
||||
|
||||
const hours = Math.floor(diffInMinutes / 60);
|
||||
const minutes = diffInMinutes % 60;
|
||||
const hourSuffix = hours == 1 ? "" : "s";
|
||||
|
||||
if (minutes == 0) {
|
||||
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
|
||||
}
|
||||
|
||||
if (hours == 0) {
|
||||
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
function setupClocks() {
|
||||
@@ -568,9 +573,11 @@ function setupClocks() {
|
||||
);
|
||||
|
||||
updateCallbacks.push((now) => {
|
||||
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
setZoneTime(time);
|
||||
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||
const { text, title } = zoneDiffText(diffInMinutes);
|
||||
diffElement.textContent = text;
|
||||
diffElement.title = title;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -602,6 +609,7 @@ async function setupPage() {
|
||||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupGroups();
|
||||
setupMasonries();
|
||||
setupDynamicRelativeTime();
|
||||
setupLazyImages();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
import { clamp } from "./utils.js";
|
||||
|
||||
export function setupMasonries() {
|
||||
const masonryContainers = document.getElementsByClassName("masonry");
|
||||
|
||||
for (let i = 0; i < masonryContainers.length; i++) {
|
||||
const container = masonryContainers[i];
|
||||
|
||||
const options = {
|
||||
minColumnWidth: container.dataset.minColumnWidth || 330,
|
||||
maxColumns: container.dataset.maxColumns || 6,
|
||||
};
|
||||
|
||||
const items = Array.from(container.children);
|
||||
let previousColumnsCount = 0;
|
||||
|
||||
const render = function() {
|
||||
const columnsCount = clamp(
|
||||
Math.floor(container.offsetWidth / options.minColumnWidth),
|
||||
1,
|
||||
Math.min(options.maxColumns, items.length)
|
||||
);
|
||||
|
||||
if (columnsCount === previousColumnsCount) {
|
||||
return;
|
||||
} else {
|
||||
container.textContent = "";
|
||||
previousColumnsCount = columnsCount;
|
||||
}
|
||||
|
||||
const columnsFragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < columnsCount; i++) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "masonry-column";
|
||||
columnsFragment.append(column);
|
||||
}
|
||||
|
||||
// poor man's masonry
|
||||
// TODO: add an option that allows placing items in the
|
||||
// shortest column instead of iterating the columns in order
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||
}
|
||||
|
||||
container.append(columnsFragment);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(() => requestAnimationFrame(render));
|
||||
observer.observe(container);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
|
||||
}
|
||||
|
||||
function showPopover() {
|
||||
if (pendingTarget === null) return;
|
||||
|
||||
activeTarget = pendingTarget;
|
||||
pendingTarget = null;
|
||||
|
||||
@@ -109,9 +111,10 @@ function repositionContainer() {
|
||||
|
||||
const containerBounds = containerElement.getBoundingClientRect();
|
||||
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
|
||||
const position = activeTarget.dataset.popoverPosition || "below";
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
|
||||
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
|
||||
|
||||
if (left < 0) {
|
||||
containerElement.style.left = 0;
|
||||
@@ -124,7 +127,7 @@ function repositionContainer() {
|
||||
} else {
|
||||
containerElement.style.removeProperty("right");
|
||||
containerElement.style.left = left + "px";
|
||||
containerElement.style.removeProperty("--triangle-offset");
|
||||
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px");
|
||||
}
|
||||
|
||||
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
export function isElementVisible(element) {
|
||||
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
}
|
||||
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -440,6 +440,17 @@ kbd:active {
|
||||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: var(--widget-gap);
|
||||
}
|
||||
|
||||
.masonry-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-container, [data-popover-html] {
|
||||
display: none;
|
||||
}
|
||||
@@ -851,6 +862,7 @@ details[open] .summary::after {
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .3s, border-color .3s;
|
||||
font-size: var(--font-size-h3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:not(.nav-item-current):hover {
|
||||
@@ -1049,6 +1061,7 @@ details[open] .summary::after {
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-icon {
|
||||
@@ -1057,7 +1070,7 @@ details[open] .summary::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:root:not(.light-scheme) .simple-icon {
|
||||
:root:not(.light-scheme) .flat-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@@ -1337,6 +1350,10 @@ details[open] .summary::after {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.clock-time span {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
@@ -1353,7 +1370,7 @@ details[open] .summary::after {
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.monitor-site-icon.simple-icon {
|
||||
.monitor-site-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -1361,7 +1378,7 @@ details[open] .summary::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
|
||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
@@ -1491,6 +1508,14 @@ details[open] .summary::after {
|
||||
border: 2px solid var(--color-widget-background);
|
||||
}
|
||||
|
||||
.twitch-stream-preview {
|
||||
max-width: 100%;
|
||||
width: 400px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -39,9 +39,11 @@ var (
|
||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||
SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
|
||||
CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
var GlobalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
@@ -58,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
Funcs(GlobalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li class="flex items-center gap-10">
|
||||
{{ if ne "" .Icon }}
|
||||
{{ if ne "" .Icon.URL }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ .CompiledHTML }}
|
||||
{{ end }}
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex items-center gap-15">
|
||||
<div class="min-width-0">
|
||||
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
<div title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
{{ end }}
|
||||
|
||||
{{ define "site" }}
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ if .Icon.URL }}
|
||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div class="min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="mobile-navigation-icons">
|
||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||
{{ range $i, $column := .Page.Columns }}
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
{{ end }}
|
||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Releases }}
|
||||
<li>
|
||||
<div class="flex items-center gap-10">
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -5,9 +5,15 @@
|
||||
{{ range .Channels }}
|
||||
<li>
|
||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
|
||||
{{ if .IsLive }}
|
||||
<div data-popover-html>
|
||||
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
|
||||
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Exists }}
|
||||
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
||||
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{{ else }}
|
||||
|
||||
+39
-18
@@ -31,10 +31,13 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
|
||||
|
||||
stats := &DNSStats{
|
||||
TotalQueries: responseJson.TotalQueries,
|
||||
BlockedQueries: responseJson.BlockedQueries,
|
||||
ResponseTime: int(responseJson.ResponseTime * 1000),
|
||||
TotalQueries: responseJson.TotalQueries,
|
||||
BlockedQueries: responseJson.BlockedQueries,
|
||||
ResponseTime: int(responseJson.ResponseTime * 1000),
|
||||
TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
|
||||
}
|
||||
|
||||
if stats.TotalQueries <= 0 {
|
||||
@@ -43,8 +46,6 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
|
||||
|
||||
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||
|
||||
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
|
||||
|
||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
||||
domain := responseJson.TopBlockedDomains[i]
|
||||
var firstDomain string
|
||||
@@ -59,31 +60,51 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
|
||||
}
|
||||
|
||||
stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
|
||||
Domain: firstDomain,
|
||||
PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
|
||||
Domain: firstDomain,
|
||||
})
|
||||
|
||||
if stats.BlockedQueries > 0 {
|
||||
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Adguard _should_ return data for the last 24 hours in a 1 hour interval
|
||||
if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
|
||||
return stats, nil
|
||||
queriesSeries := responseJson.QueriesSeries
|
||||
blockedSeries := responseJson.BlockedSeries
|
||||
|
||||
const bars = 8
|
||||
const hoursSpan = 24
|
||||
const hoursPerBar int = hoursSpan / bars
|
||||
|
||||
if len(queriesSeries) > hoursSpan {
|
||||
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
|
||||
} else if len(queriesSeries) < hoursSpan {
|
||||
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
|
||||
}
|
||||
|
||||
if len(blockedSeries) > hoursSpan {
|
||||
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
|
||||
} else if len(blockedSeries) < hoursSpan {
|
||||
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
|
||||
}
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := 0; i < bars; i++ {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < 3; j++ {
|
||||
queries += responseJson.QueriesSeries[i*3+j]
|
||||
blocked += responseJson.BlockedSeries[i*3+j]
|
||||
for j := 0; j < hoursPerBar; j++ {
|
||||
queries += queriesSeries[i*hoursPerBar+j]
|
||||
blocked += blockedSeries[i*hoursPerBar+j]
|
||||
}
|
||||
|
||||
stats.Series[i] = DNSStatsSeries{
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
PercentBlocked: int(float64(blocked) / float64(queries) * 100),
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
}
|
||||
|
||||
if queries > 0 {
|
||||
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||
}
|
||||
|
||||
if queries > maxQueriesInSeries {
|
||||
@@ -91,7 +112,7 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := 0; i < bars; i++ {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type codebergReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
}
|
||||
|
||||
func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
|
||||
httpRequest, err := http.NewRequest(
|
||||
"GET",
|
||||
fmt.Sprintf(
|
||||
"https://codeberg.org/api/v1/repos/%s/releases/latest",
|
||||
request.Repository,
|
||||
),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AppRelease{
|
||||
Source: ReleaseSourceCodeberg,
|
||||
Name: request.Repository,
|
||||
Version: normalizeVersionFormat(response.TagName),
|
||||
NotesUrl: response.HtmlUrl,
|
||||
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
|
||||
emptyBody := template.HTML("")
|
||||
|
||||
resp, err := defaultClient.Do(req)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
}
|
||||
|
||||
body := string(bodyBytes)
|
||||
|
||||
if !gjson.Valid(body) {
|
||||
truncatedBody, isTruncated := limitStringLength(body, 100)
|
||||
if isTruncated {
|
||||
truncatedBody += "... <truncated>"
|
||||
}
|
||||
|
||||
slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody)
|
||||
return emptyBody, errors.New("invalid response JSON")
|
||||
}
|
||||
|
||||
var templateBuffer bytes.Buffer
|
||||
|
||||
data := CustomAPITemplateData{
|
||||
JSON: DecoratedGJSONResult{gjson.Parse(body)},
|
||||
Response: resp,
|
||||
}
|
||||
|
||||
err = tmpl.Execute(&templateBuffer, &data)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
}
|
||||
|
||||
return template.HTML(templateBuffer.String()), nil
|
||||
}
|
||||
|
||||
type DecoratedGJSONResult struct {
|
||||
gjson.Result
|
||||
}
|
||||
|
||||
type CustomAPITemplateData struct {
|
||||
JSON DecoratedGJSONResult
|
||||
Response *http.Response
|
||||
}
|
||||
|
||||
func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult {
|
||||
decoratedResults := make([]DecoratedGJSONResult, len(results))
|
||||
|
||||
for i, result := range results {
|
||||
decoratedResults[i] = DecoratedGJSONResult{result}
|
||||
}
|
||||
|
||||
return decoratedResults
|
||||
}
|
||||
|
||||
func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult {
|
||||
if key == "" {
|
||||
return GJsonResultArrayToDecoratedResultArray(r.Result.Array())
|
||||
}
|
||||
|
||||
return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
|
||||
}
|
||||
|
||||
func (r *DecoratedGJSONResult) String(key string) string {
|
||||
if key == "" {
|
||||
return r.Result.String()
|
||||
}
|
||||
|
||||
return r.Get(key).String()
|
||||
}
|
||||
|
||||
func (r *DecoratedGJSONResult) Int(key string) int64 {
|
||||
if key == "" {
|
||||
return r.Result.Int()
|
||||
}
|
||||
|
||||
return r.Get(key).Int()
|
||||
}
|
||||
|
||||
func (r *DecoratedGJSONResult) Float(key string) float64 {
|
||||
if key == "" {
|
||||
return r.Result.Float()
|
||||
}
|
||||
|
||||
return r.Get(key).Float()
|
||||
}
|
||||
|
||||
func (r *DecoratedGJSONResult) Bool(key string) bool {
|
||||
if key == "" {
|
||||
return r.Result.Bool()
|
||||
}
|
||||
|
||||
return r.Get(key).Bool()
|
||||
}
|
||||
|
||||
var CustomAPITemplateFuncs = func() template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"toFloat": func(a int64) float64 {
|
||||
return float64(a)
|
||||
},
|
||||
"toInt": func(a float64) int64 {
|
||||
return int64(a)
|
||||
},
|
||||
"mathexpr": func(left float64, op string, right float64) float64 {
|
||||
if right == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "+":
|
||||
return left + right
|
||||
case "-":
|
||||
return left - right
|
||||
case "*":
|
||||
return left * right
|
||||
case "/":
|
||||
return left / right
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for key, value := range assets.GlobalTemplateFunctions {
|
||||
funcs[key] = value
|
||||
}
|
||||
|
||||
return funcs
|
||||
}()
|
||||
@@ -27,9 +27,10 @@ const (
|
||||
)
|
||||
|
||||
type ExtensionRequestOptions struct {
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
}
|
||||
|
||||
type Extension struct {
|
||||
@@ -88,7 +89,11 @@ func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
||||
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
contentType, ok = ExtensionStringToType[options.FallbackContentType]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
}
|
||||
}
|
||||
|
||||
extension.Content = convertExtensionContent(options, body, contentType)
|
||||
|
||||
@@ -189,12 +189,19 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
||||
minT := slices.Min(temperatures)
|
||||
maxT := slices.Max(temperatures)
|
||||
|
||||
temperaturesRange := float64(maxT - minT)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
bars = append(bars, weatherColumn{
|
||||
Temperature: temperatures[i],
|
||||
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||
HasPrecipitation: precipitations[i],
|
||||
})
|
||||
|
||||
if temperaturesRange > 0 {
|
||||
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
|
||||
} else {
|
||||
bars[i].Scale = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-7
@@ -1,20 +1,42 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type piholeStatsResponse struct {
|
||||
TotalQueries int `json:"dns_queries_today"`
|
||||
QueriesSeries map[int64]int `json:"domains_over_time"`
|
||||
BlockedQueries int `json:"ads_blocked_today"`
|
||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||
TopBlockedDomains map[string]int `json:"top_ads"`
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
TotalQueries int `json:"dns_queries_today"`
|
||||
QueriesSeries map[int64]int `json:"domains_over_time"`
|
||||
BlockedQueries int `json:"ads_blocked_today"`
|
||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
}
|
||||
|
||||
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
||||
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
||||
type piholeTopBlockedDomains map[string]int
|
||||
|
||||
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
||||
// because of the UnmarshalJSON method getting called recursively
|
||||
temp := make(map[string]int)
|
||||
|
||||
err := json.Unmarshal(data, &temp)
|
||||
|
||||
if err != nil {
|
||||
*p = make(piholeTopBlockedDomains)
|
||||
} else {
|
||||
*p = temp
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
|
||||
@@ -63,6 +85,11 @@ func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
|
||||
|
||||
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
|
||||
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
|
||||
slog.Warn(
|
||||
"DNS stats for pihole: did not get expected 144 data points",
|
||||
"len(queries)", len(responseJson.QueriesSeries),
|
||||
"len(blocked)", len(responseJson.BlockedSeries),
|
||||
)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,12 @@ func (t Markets) SortByAbsChange() {
|
||||
})
|
||||
}
|
||||
|
||||
func (t Markets) SortByChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return t[i].PercentChange > t[j].PercentChange
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type ReleaseSource string
|
||||
|
||||
const (
|
||||
ReleaseSourceCodeberg ReleaseSource = "codeberg"
|
||||
ReleaseSourceGithub ReleaseSource = "github"
|
||||
ReleaseSourceGitlab ReleaseSource = "gitlab"
|
||||
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
|
||||
@@ -57,6 +58,8 @@ func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
|
||||
|
||||
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
|
||||
switch request.Source {
|
||||
case ReleaseSourceCodeberg:
|
||||
return fetchLatestCodebergRelease(request)
|
||||
case ReleaseSourceGithub:
|
||||
return fetchLatestGithubRelease(request)
|
||||
case ReleaseSourceGitlab:
|
||||
|
||||
+33
-10
@@ -1,10 +1,11 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -57,12 +58,13 @@ func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||
}
|
||||
|
||||
type RSSFeedRequest struct {
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
HideCategories bool `yaml:"hide-categories"`
|
||||
HideDescription bool `yaml:"hide-description"`
|
||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||
IsDetailed bool `yaml:"-"`
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
HideCategories bool `yaml:"hide-categories"`
|
||||
HideDescription bool `yaml:"hide-description"`
|
||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
IsDetailed bool `yaml:"-"`
|
||||
}
|
||||
|
||||
type RSSFeedItems []RSSFeedItem
|
||||
@@ -78,10 +80,31 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
||||
var feedParser = gofeed.NewParser()
|
||||
|
||||
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
req, err := http.NewRequest("GET", request.Url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
||||
for key, value := range request.Headers {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
|
||||
resp, err := defaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.Url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := feedParser.ParseString(string(body))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+12
-1
@@ -28,6 +28,7 @@ type TwitchChannel struct {
|
||||
Login string
|
||||
Exists bool
|
||||
Name string
|
||||
StreamTitle string
|
||||
AvatarUrl string
|
||||
IsLive bool
|
||||
LiveSince time.Time
|
||||
@@ -77,6 +78,9 @@ type twitchStreamMetadataOperationResponse struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"game"`
|
||||
} `json:"stream"`
|
||||
LastBroadcast *struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
@@ -142,7 +146,10 @@ func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, err
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||
const twitchChannelStatusOperationRequestBody = `[
|
||||
{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
|
||||
{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
|
||||
]`
|
||||
|
||||
// TODO: rework
|
||||
// The operations for multiple channels can all be sent in a single request
|
||||
@@ -205,6 +212,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||
|
||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
||||
if streamMetadata.UserOrNull.LastBroadcast != nil {
|
||||
result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
|
||||
}
|
||||
|
||||
if streamMetadata.UserOrNull.Stream.Game != nil {
|
||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||
|
||||
@@ -76,6 +76,7 @@ type Page struct {
|
||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
CenterVertically bool `yaml:"center-vertically"`
|
||||
Columns []Column `yaml:"columns"`
|
||||
PrimaryColumnIndex int8 `yaml:"-"`
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -141,15 +142,24 @@ func NewApplication(config *Config) (*Application, error) {
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
if config.Pages[p].Slug == "" {
|
||||
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
||||
page := &config.Pages[p]
|
||||
page.PrimaryColumnIndex = -1
|
||||
|
||||
if page.Slug == "" {
|
||||
page.Slug = titleToSlug(page.Title)
|
||||
}
|
||||
|
||||
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
||||
app.slugToPage[page.Slug] = page
|
||||
|
||||
for c := range config.Pages[p].Columns {
|
||||
for w := range config.Pages[p].Columns[c].Widgets {
|
||||
widget := config.Pages[p].Columns[c].Widgets[w]
|
||||
for c := range page.Columns {
|
||||
column := &page.Columns[c]
|
||||
|
||||
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
|
||||
page.PrimaryColumnIndex = int8(c)
|
||||
}
|
||||
|
||||
for w := range column.Widgets {
|
||||
widget := column.Widgets[w]
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.SetProviders(providers)
|
||||
@@ -276,6 +286,9 @@ func (a *Application) Serve() error {
|
||||
|
||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.Handle(
|
||||
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
||||
|
||||
@@ -13,30 +13,17 @@ type Bookmarks struct {
|
||||
Title string `yaml:"title"`
|
||||
Color *HSLColorField `yaml:"color"`
|
||||
Links []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
IsSimpleIcon bool `yaml:"-"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
HideArrow bool `yaml:"hide-arrow"`
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon CustomIcon `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
HideArrow bool `yaml:"hide-arrow"`
|
||||
} `yaml:"links"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
|
||||
func (widget *Bookmarks) Initialize() error {
|
||||
widget.withTitle("Bookmarks").withError(nil)
|
||||
|
||||
for g := range widget.Groups {
|
||||
for l := range widget.Groups[g].Links {
|
||||
if widget.Groups[g].Links[l].Icon == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
link := &widget.Groups[g].Links[l]
|
||||
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
|
||||
}
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type containerWidgetBase struct {
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) Update(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
now := time.Now()
|
||||
|
||||
for w := range widget.Widgets {
|
||||
widget := widget.Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) SetProviders(providers *Providers) {
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetProviders(providers)
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
for i := range widget.Widgets {
|
||||
if widget.Widgets[i].RequiresUpdate(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type CustomApi struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL OptionalEnvString `yaml:"url"`
|
||||
Template string `yaml:"template"`
|
||||
Frameless bool `yaml:"frameless"`
|
||||
Headers map[string]OptionalEnvString `yaml:"headers"`
|
||||
APIRequest *http.Request `yaml:"-"`
|
||||
compiledTemplate *template.Template `yaml:"-"`
|
||||
CompiledHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Initialize() error {
|
||||
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.URL == "" {
|
||||
return errors.New("URL is required for the custom API widget")
|
||||
}
|
||||
|
||||
if widget.Template == "" {
|
||||
return errors.New("template is required for the custom API widget")
|
||||
}
|
||||
|
||||
compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed parsing custom API widget template: %w", err)
|
||||
}
|
||||
|
||||
widget.compiledTemplate = compiledTemplate
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, value := range widget.Headers {
|
||||
req.Header.Add(key, value.String())
|
||||
}
|
||||
|
||||
widget.APIRequest = req
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Update(ctx context.Context) {
|
||||
compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.CompiledHTML = compiledHTML
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Render() template.HTML {
|
||||
return widget.render(widget, assets.CustomAPITemplate)
|
||||
}
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type Extension struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Extension) Initialize() error {
|
||||
@@ -38,9 +39,10 @@ func (widget *Extension) Initialize() error {
|
||||
|
||||
func (widget *Extension) Update(ctx context.Context) {
|
||||
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||
URL: widget.URL,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
URL: widget.URL,
|
||||
FallbackContentType: widget.FallbackContentType,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
})
|
||||
|
||||
widget.canContinueUpdateAfterHandlingErr(err)
|
||||
|
||||
+75
-18
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
|
||||
|
||||
const (
|
||||
HSLHueMax = 360
|
||||
@@ -133,21 +133,42 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := EnvFieldPattern.FindStringSubmatch(value)
|
||||
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(matches) != 2 {
|
||||
*f = OptionalEnvString(value)
|
||||
groups := EnvFieldPattern.FindStringSubmatch(whole)
|
||||
|
||||
return nil
|
||||
if len(groups) != 3 {
|
||||
return whole
|
||||
}
|
||||
|
||||
prefix, key := groups[1], groups[2]
|
||||
|
||||
if prefix == `\` {
|
||||
if len(whole) >= 2 {
|
||||
return whole[1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(key)
|
||||
|
||||
if !found {
|
||||
err = fmt.Errorf("environment variable %s not found", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return prefix + value
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(matches[1])
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("environment variable %s not found", matches[1])
|
||||
}
|
||||
|
||||
*f = OptionalEnvString(value)
|
||||
*f = OptionalEnvString(replaced)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -156,13 +177,49 @@ func (f *OptionalEnvString) String() string {
|
||||
return string(*f)
|
||||
}
|
||||
|
||||
func toSimpleIconIfPrefixed(icon string) (string, bool) {
|
||||
if !strings.HasPrefix(icon, "si:") {
|
||||
return icon, false
|
||||
type CustomIcon struct {
|
||||
URL string
|
||||
IsFlatIcon bool
|
||||
// TODO: along with whether the icon is flat, we also need to know
|
||||
// whether the icon is black or white by default in order to properly
|
||||
// invert the color based on the theme being light or dark
|
||||
}
|
||||
|
||||
func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
icon = strings.TrimPrefix(icon, "si:")
|
||||
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||
prefix, icon, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
i.URL = value
|
||||
return nil
|
||||
}
|
||||
|
||||
return icon, true
|
||||
switch prefix {
|
||||
case "si":
|
||||
i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
||||
i.IsFlatIcon = true
|
||||
case "di":
|
||||
// syntax: di:<icon_name>[.svg|.png]
|
||||
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
|
||||
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
|
||||
// any other extension will be interpreted as .svg
|
||||
basename, ext, found := strings.Cut(icon, ".")
|
||||
if !found {
|
||||
ext = "svg"
|
||||
basename = icon
|
||||
}
|
||||
|
||||
if ext != "svg" && ext != "png" {
|
||||
ext = "svg"
|
||||
}
|
||||
|
||||
i.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
|
||||
default:
|
||||
i.URL = value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,15 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (widget *Group) Initialize() error {
|
||||
@@ -23,7 +22,9 @@ func (widget *Group) Initialize() error {
|
||||
widget.Widgets[i].SetHideHeader(true)
|
||||
|
||||
if widget.Widgets[i].GetType() == "group" {
|
||||
return errors.New("nested groups are not allowed")
|
||||
return errors.New("nested groups are not supported")
|
||||
} else if widget.Widgets[i].GetType() == "split-column" {
|
||||
return errors.New("split columns inside of groups are not supported")
|
||||
}
|
||||
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
@@ -35,40 +36,15 @@ func (widget *Group) Initialize() error {
|
||||
}
|
||||
|
||||
func (widget *Group) Update(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
now := time.Now()
|
||||
|
||||
for w := range widget.Widgets {
|
||||
widget := widget.Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *Group) SetProviders(providers *Providers) {
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetProviders(providers)
|
||||
}
|
||||
widget.containerWidgetBase.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (widget *Group) RequiresUpdate(now *time.Time) bool {
|
||||
for i := range widget.Widgets {
|
||||
if widget.Widgets[i].RequiresUpdate(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *Group) Render() template.HTML {
|
||||
|
||||
@@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
|
||||
markets.SortByAbsChange()
|
||||
}
|
||||
|
||||
if widget.Sort == "change" {
|
||||
markets.SortByChange()
|
||||
}
|
||||
|
||||
widget.Markets = markets
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -3,6 +3,7 @@ package widget
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -10,8 +11,8 @@ import (
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
func statusCodeToText(status int) string {
|
||||
if status == 200 {
|
||||
func statusCodeToText(status int, altStatusCodes []int) string {
|
||||
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||
return "OK"
|
||||
}
|
||||
if status == 404 {
|
||||
@@ -33,8 +34,8 @@ func statusCodeToText(status int) string {
|
||||
return strconv.Itoa(status)
|
||||
}
|
||||
|
||||
func statusCodeToStyle(status int) string {
|
||||
if status == 200 {
|
||||
func statusCodeToStyle(status int, altStatusCodes []int) string {
|
||||
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
@@ -47,11 +48,11 @@ type Monitor struct {
|
||||
*feed.SiteStatusRequest `yaml:",inline"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
Title string `yaml:"title"`
|
||||
IconUrl string `yaml:"icon"`
|
||||
IsSimpleIcon bool `yaml:"-"`
|
||||
Icon CustomIcon `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
AltStatusCodes []int `yaml:"alt-status-codes"`
|
||||
} `yaml:"sites"`
|
||||
ShowFailingOnly bool `yaml:"show-failing-only"`
|
||||
HasFailing bool `yaml:"-"`
|
||||
@@ -60,10 +61,6 @@ type Monitor struct {
|
||||
func (widget *Monitor) Initialize() error {
|
||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||
|
||||
for i := range widget.Sites {
|
||||
widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -87,13 +84,13 @@ func (widget *Monitor) Update(ctx context.Context) {
|
||||
status := &statuses[i]
|
||||
site.Status = status
|
||||
|
||||
if status.Code >= 400 || status.TimedOut || status.Error != nil {
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||
widget.HasFailing = true
|
||||
}
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code)
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ func (widget *Releases) Initialize() error {
|
||||
for _, repository := range widget.Repositories {
|
||||
parts := strings.SplitN(repository, ":", 2)
|
||||
var request *feed.ReleaseRequest
|
||||
|
||||
if len(parts) == 1 {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceGithub,
|
||||
@@ -65,6 +64,11 @@ func (widget *Releases) Initialize() error {
|
||||
Source: feed.ReleaseSourceDockerHub,
|
||||
Repository: parts[1],
|
||||
}
|
||||
} else if parts[0] == string(feed.ReleaseSourceCodeberg) {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceCodeberg,
|
||||
Repository: parts[1],
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid repository source " + parts[0])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SplitColumn struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
MaxColumns int `yaml:"max-columns"`
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Initialize() error {
|
||||
widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
|
||||
|
||||
for i := range widget.Widgets {
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if widget.MaxColumns < 2 {
|
||||
widget.MaxColumns = 2
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Update(ctx context.Context) {
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) SetProviders(providers *Providers) {
|
||||
widget.containerWidgetBase.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Render() template.HTML {
|
||||
return widget.render(widget, assets.SplitColumnTemplate)
|
||||
}
|
||||
@@ -67,6 +67,10 @@ func New(widgetType string) (Widget, error) {
|
||||
widget = &Group{}
|
||||
case "dns-stats":
|
||||
widget = &DNSStats{}
|
||||
case "split-column":
|
||||
widget = &SplitColumn{}
|
||||
case "custom-api":
|
||||
widget = &CustomApi{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user