Merge remote-tracking branch 'origin/benphelpsMain' into LocalMain

This commit is contained in:
Karl Hudgell 2024-01-17 16:24:58 +00:00
commit b4094b316e
16 changed files with 99 additions and 38 deletions

View File

@ -164,10 +164,10 @@ labels:
- homepage.description=Media server - homepage.description=Media server
- homepage.widget.type=customapi - homepage.widget.type=customapi
- homepage.widget.url=http://argus.service/api/v1/service/summary/emby - homepage.widget.url=http://argus.service/api/v1/service/summary/emby
- homepage.widget.field[0].label=Deployed Version - homepage.widget.mappings[0].label=Deployed Version
- homepage.widget.field[0].field.status=deployed_version - homepage.widget.mappings[0].field.status=deployed_version
- homepage.widget.field[1].label=Latest Version - homepage.widget.mappings[1].label=Latest Version
- homepage.widget.field[1].field.status=latest_version - homepage.widget.mappings[1].field.status=latest_version
``` ```
## Docker Swarm ## Docker Swarm

View File

@ -27,6 +27,7 @@ widget:
url: https://domain.url/with/link/to.ics # URL with calendar events url: https://domain.url/with/link/to.ics # URL with calendar events
name: My Events # required - name for these calendar events name: My Events # required - name for these calendar events
color: zinc # optional - defaults to pre-defined color for the service (zinc for ical) color: zinc # optional - defaults to pre-defined color for the service (zinc for ical)
timezone: America/Los_Angeles # optional - force timezone for events (if it's the same - no change, if missing or different in ical - will be converted to this timezone)
params: # optional - additional params for the service params: # optional - additional params for the service
showName: true # optional - show name before event title in event line - defaults to false showName: true # optional - show name before event title in event line - defaults to false
``` ```

View File

@ -3,18 +3,21 @@ title: Health checks
description: Health checks Widget Configuration description: Health checks Widget Configuration
--- ---
To use the Health Checks widget, you first need to generate an API key. To do this, follow these steps: Specify a single check by including the `uuid` field or show the total 'up' and 'down' for all
checks by leaving off the `uuid` field.
1. Go to Settings in your check dashboard. To use the Health Checks widget, you first need to generate an API key.
1. In your project, go to project Settings on the navigation bar.
2. Click on API key (read-only) and then click _Create_. 2. Click on API key (read-only) and then click _Create_.
3. Copy the API key that is generated for you. 3. Copy the API key that is generated for you.
Allowed fields: `["status", "last_ping"]`. Allowed fields: `["status", "last_ping"]` for single checks, `["up", "down"]` for total stats.
```yaml ```yaml
widget: widget:
type: healthchecks type: healthchecks
url: http://healthchecks.host.or.ip:port url: http://healthchecks.host.or.ip:port
key: <YOUR_API_KEY> key: <YOUR_API_KEY>
uuid: <YOUR_CHECK_UUID> uuid: <CHECK_UUID> # optional, if not included total statistics for all checks is shown
``` ```

View File

@ -491,9 +491,9 @@
}, },
"healthchecks": { "healthchecks": {
"new": "New", "new": "New",
"up": "Online", "up": "Up",
"grace": "In Grace Period", "grace": "In Grace Period",
"down": "Offline", "down": "Down",
"paused": "Paused", "paused": "Paused",
"status": "Status", "status": "Status",
"last_ping": "Last Ping", "last_ping": "Last Ping",

View File

@ -199,7 +199,7 @@ export default function QuickLaunch({
{results.length > 0 && ( {results.length > 0 && (
<ul className="max-h-[60vh] overflow-y-auto m-2"> <ul className="max-h-[60vh] overflow-y-auto m-2">
{results.map((r, i) => ( {results.map((r, i) => (
<li key={r.container ?? r.app ?? `${r.name}-${r.href}`}> <li key={[r.name, r.container, r.app, r.href].filter((s) => s).join("-")}>
<button <button
type="button" type="button"
data-index={i} data-index={i}

View File

@ -49,6 +49,7 @@ export default function Item({ service, group, useEqualHeights }) {
target={service.target ?? settings.target ?? "_blank"} target={service.target ?? settings.target ?? "_blank"}
rel="noreferrer" rel="noreferrer"
className="flex-shrink-0 flex items-center justify-center w-12 service-icon" className="flex-shrink-0 flex items-center justify-center w-12 service-icon"
aria-label={service.icon}
> >
<ResolvedIcon icon={service.icon} /> <ResolvedIcon icon={service.icon} />
</a> </a>

View File

@ -14,7 +14,7 @@ export default function List({ group, services, layout, useEqualHeights }) {
> >
{services.map((service) => ( {services.map((service) => (
<Item <Item
key={service.container ?? service.app ?? service.name} key={[service.container, service.app, service.name].filter((s) => s).join("-")}
service={service} service={service}
group={group} group={group}
useEqualHeights={layout?.useEqualHeights ?? useEqualHeights} useEqualHeights={layout?.useEqualHeights ?? useEqualHeights}

View File

@ -1,10 +1,11 @@
export default function UsageBar({ percent, additionalClassNames = "" }) { export default function UsageBar({ percent, additionalClassNames = "" }) {
const normalized = Math.min(100, Math.max(0, percent));
return ( return (
<div className={`mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20 ${additionalClassNames}`}> <div className={`mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20 ${additionalClassNames}`}>
<div <div
className="bg-theme-800/70 h-1 rounded-full dark:bg-theme-200/50 transition-all duration-1000" className="bg-theme-800/70 h-1 rounded-full dark:bg-theme-200/50 transition-all duration-1000"
style={{ style={{
width: `${percent}%`, width: `${normalized}%`,
}} }}
/> />
</div> </div>

View File

@ -38,7 +38,7 @@ export async function servicesFromConfig() {
// add default weight to services based on their position in the configuration // add default weight to services based on their position in the configuration
servicesArray.forEach((group, groupIndex) => { servicesArray.forEach((group, groupIndex) => {
group.services.forEach((service, serviceIndex) => { group.services.forEach((service, serviceIndex) => {
if (!service.weight) { if (service.weight === undefined) {
servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100; servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100;
} }
}); });
@ -102,6 +102,16 @@ export async function servicesFromDocker() {
} }
}); });
if (!constructedService.name || !constructedService.group) {
logger.error(
`Error constructing service using homepage labels for container '${containerName.replace(
/^\//,
"",
)}'. Ensure required labels are present.`,
);
return null;
}
return constructedService; return constructedService;
}); });
@ -387,6 +397,9 @@ export function cleanServiceGroups(groups) {
// glances, customapi, iframe // glances, customapi, iframe
refreshInterval, refreshInterval,
// healthchecks
uuid,
// iframe // iframe
allowFullscreen, allowFullscreen,
allowPolicy, allowPolicy,
@ -526,6 +539,9 @@ export function cleanServiceGroups(groups) {
if (previousDays) cleanedService.widget.previousDays = previousDays; if (previousDays) cleanedService.widget.previousDays = previousDays;
if (showTime) cleanedService.widget.showTime = showTime; if (showTime) cleanedService.widget.showTime = showTime;
} }
if (type === "healthchecks") {
if (uuid !== undefined) cleanedService.widget.uuid = uuid;
}
} }
return cleanedService; return cleanedService;

View File

@ -2,7 +2,7 @@ import { DateTime } from "luxon";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Event from "./event"; import Event, { compareDateTimezoneAware } from "./event";
export default function Agenda({ service, colorVariants, events, showDate }) { export default function Agenda({ service, colorVariants, events, showDate }) {
const { widget } = service; const { widget } = service;
@ -15,8 +15,10 @@ export default function Agenda({ service, colorVariants, events, showDate }) {
const eventsArray = Object.keys(events) const eventsArray = Object.keys(events)
.filter( .filter(
(eventKey) => (eventKey) =>
showDate.minus({ days: widget?.previousDays ?? 0 }).startOf("day").ts <= showDate
events[eventKey].date?.startOf("day").ts, .setZone(events[eventKey].date.zoneName)
.minus({ days: widget?.previousDays ?? 0 })
.startOf("day").ts <= events[eventKey].date?.startOf("day").ts,
) )
.map((eventKey) => events[eventKey]) .map((eventKey) => events[eventKey])
.sort((a, b) => a.date - b.date) .sort((a, b) => a.date - b.date)
@ -56,7 +58,7 @@ export default function Agenda({ service, colorVariants, events, showDate }) {
event={event} event={event}
colorVariants={colorVariants} colorVariants={colorVariants}
showDate={j === 0} showDate={j === 0}
showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts} showTime={widget?.showTime && compareDateTimezoneAware(showDate, event)}
/> />
))} ))}
</div> </div>

View File

@ -39,3 +39,7 @@ export default function Event({ event, colorVariants, showDate = false, showTime
</div> </div>
); );
} }
export function compareDateTimezoneAware(date, event) {
return date.setZone(event.date.zoneName).startOf("day").valueOf() === event.date.startOf("day").valueOf();
}

View File

@ -23,8 +23,9 @@ export default function Integration({ config, params, setEvents, hideErrors }) {
} }
} }
const startDate = DateTime.fromISO(params.start); const zone = config?.timezone || null;
const endDate = DateTime.fromISO(params.end); const startDate = DateTime.fromISO(params.start, { zone });
const endDate = DateTime.fromISO(params.end, { zone });
if (icalError || !parsedIcal || !startDate.isValid || !endDate.isValid) { if (icalError || !parsedIcal || !startDate.isValid || !endDate.isValid) {
return; return;
@ -43,20 +44,24 @@ export default function Integration({ config, params, setEvents, hideErrors }) {
const duration = event.dtend.value - event.dtstart.value; const duration = event.dtend.value - event.dtstart.value;
const days = duration / (1000 * 60 * 60 * 24); const days = duration / (1000 * 60 * 60 * 24);
const now = DateTime.now().setZone(zone);
const eventDate = DateTime.fromJSDate(date, { zone });
for (let j = 0; j < days; j += 1) { for (let j = 0; j < days; j += 1) {
eventsToAdd[`${event?.uid?.value}${i}${j}${type}`] = { eventsToAdd[`${event?.uid?.value}${i}${j}${type}`] = {
title, title,
date: DateTime.fromJSDate(date).plus({ days: j }), date: eventDate.plus({ days: j }),
color: config?.color ?? "zinc", color: config?.color ?? "zinc",
isCompleted: DateTime.fromJSDate(date) < DateTime.now(), isCompleted: eventDate < now,
additional: event.location?.value, additional: event.location?.value,
type: "ical", type: "ical",
}; };
} }
}; };
if (event?.recurrenceRule?.options) { const recurrenceOptions = event?.recurrenceRule?.origOptions;
const rule = new RRule(event.recurrenceRule.options); if (recurrenceOptions && Object.keys(recurrenceOptions).length !== 0) {
const rule = new RRule(recurrenceOptions);
const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate()); const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate());
recurringEvents.forEach((date, i) => eventToAdd(date, i, "recurring")); recurringEvents.forEach((date, i) => eventToAdd(date, i, "recurring"));

View File

@ -3,7 +3,7 @@ import { DateTime, Info } from "luxon";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Event from "./event"; import Event, { compareDateTimezoneAware } from "./event";
const cellStyle = "relative w-10 flex items-center justify-center flex-col"; const cellStyle = "relative w-10 flex items-center justify-center flex-col";
const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer"; const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer";
@ -12,9 +12,7 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
const currentDate = DateTime.now(); const currentDate = DateTime.now();
const cellDate = showDate.set({ weekday, weekNumber }).startOf("day"); const cellDate = showDate.set({ weekday, weekNumber }).startOf("day");
const filteredEvents = events?.filter( const filteredEvents = events?.filter((event) => compareDateTimezoneAware(cellDate, event));
(event) => event.date?.startOf("day").toUnixInteger() === cellDate.toUnixInteger(),
);
const dayStyles = (displayDate) => { const dayStyles = (displayDate) => {
let style = "h-9 "; let style = "h-9 ";
@ -111,6 +109,7 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
} }
const eventsArray = Object.keys(events).map((eventKey) => events[eventKey]); const eventsArray = Object.keys(events).map((eventKey) => events[eventKey]);
eventsArray.sort((a, b) => a.date - b.date);
return ( return (
<div className="w-full text-center"> <div className="w-full text-center">
@ -172,7 +171,7 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
<div className="flex flex-col"> <div className="flex flex-col">
{eventsArray {eventsArray
?.filter((event) => showDate.startOf("day").ts === event.date?.startOf("day").ts) ?.filter((event) => compareDateTimezoneAware(showDate, event))
.slice(0, widget?.maxEvents ?? 10) .slice(0, widget?.maxEvents ?? 10)
.map((event) => ( .map((event) => (
<Event <Event
@ -180,7 +179,7 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
event={event} event={event}
colorVariants={colorVariants} colorVariants={colorVariants}
showDateColumn={widget?.showTime ?? false} showDateColumn={widget?.showTime ?? false}
showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts} showTime={widget?.showTime && compareDateTimezoneAware(showDate, event)}
/> />
))} ))}
</div> </div>

View File

@ -27,6 +27,23 @@ function formatDate(dateString) {
return new Intl.DateTimeFormat(i18n.language, dateOptions).format(date); return new Intl.DateTimeFormat(i18n.language, dateOptions).format(date);
} }
function countStatus(data) {
let upCount = 0;
let downCount = 0;
if (data.checks) {
data.checks.forEach((check) => {
if (check.status === "up") {
upCount += 1;
} else if (check.status === "down") {
downCount += 1;
}
});
}
return { upCount, downCount };
}
export default function Component({ service }) { export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
@ -46,13 +63,26 @@ export default function Component({ service }) {
); );
} }
const hasUuid = widget?.uuid;
const { upCount, downCount } = countStatus(data);
return ( return (
<Container service={service}> <Container service={service}>
<Block label="healthchecks.status" value={t(`healthchecks.${data.status}`)} /> {hasUuid ? (
<Block <>
label="healthchecks.last_ping" <Block label="healthchecks.status" value={t(`healthchecks.${data.status}`)} />
value={data.last_ping ? formatDate(data.last_ping) : t("healthchecks.never")} <Block
/> label="healthchecks.last_ping"
value={data.last_ping ? formatDate(data.last_ping) : t("healthchecks.never")}
/>
</>
) : (
<>
<Block label="healthchecks.up" value={upCount} />
<Block label="healthchecks.down" value={downCount} />
</>
)}
</Container> </Container>
); );
} }

View File

@ -1,13 +1,12 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = { const widget = {
api: "{url}/api/v2/{endpoint}/{uuid}", api: "{url}/api/v3/{endpoint}/{uuid}",
proxyHandler: credentialedProxyHandler, proxyHandler: credentialedProxyHandler,
mappings: { mappings: {
checks: { checks: {
endpoint: "checks", endpoint: "checks",
validate: ["status", "last_ping"],
}, },
}, },
}; };

View File

@ -64,7 +64,7 @@ async function tryLogin(widget) {
const resp = await rpc(url, { const resp = await rpc(url, {
method: "login", method: "login",
service: "session", service: "session",
params: { username, password }, params: { username: username.toString(), password: password.toString() },
}); });
if (resp.status !== 200) { if (resp.status !== 200) {