mirror of
https://github.com/gethomepage/homepage.git
synced 2025-12-05 21:47:48 +01:00
Compare commits
13 Commits
v1.5.0
...
3f8da51aeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8da51aeb | ||
|
|
837717461f | ||
|
|
effedc28ed | ||
|
|
76b477572e | ||
|
|
6c6660b91b | ||
|
|
6886040798 | ||
|
|
1fb4850bef | ||
|
|
06cf76d724 | ||
|
|
7aeda56af4 | ||
|
|
2058b7fcae | ||
|
|
1e06e93e47 | ||
|
|
8f756d4084 | ||
|
|
02089a35ee |
@@ -178,3 +178,32 @@ See [ClusterRole and ClusterRoleBinding](../installation/k8s.md#clusterrole-and-
|
||||
## Caveats
|
||||
|
||||
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.
|
||||
|
||||
## Adding extra configuration files
|
||||
|
||||
Some Homepage features (for example, [Proxmox](../configs/proxmox.md)) require additional configuration files such as `proxmox.yaml`.
|
||||
When running Homepage on Kubernetes, these files must be provided via a `ConfigMap` and mounted into the container at `/app/config`.
|
||||
|
||||
### ConfigMap example
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: homepage
|
||||
data:
|
||||
proxmox.yaml: |
|
||||
pve:
|
||||
url: https://proxmox.host.or.ip:8006
|
||||
token: username@pam!Token ID
|
||||
secret: secret
|
||||
```
|
||||
|
||||
Mount the file into `/app/config` by updating the `Deployment`:
|
||||
|
||||
```yaml
|
||||
volumeMounts:
|
||||
- mountPath: /app/config/proxmox.yaml
|
||||
name: homepage-config
|
||||
subPath: proxmox.yaml
|
||||
```
|
||||
|
||||
@@ -4,10 +4,10 @@ description: Proxmox Configuration
|
||||
---
|
||||
|
||||
The Proxmox connection is configured in the `proxmox.yaml` file. See [Create token](#create-token) section below for details on how to generate the required API token.
|
||||
You can configure multiple nodes - be sure to use the exact `proxmoxNode` identifier!
|
||||
To configure multiple nodes, ensure the key name in the `proxmox.yaml` matches the `proxmoxNode` field used in your service configuration.
|
||||
|
||||
```yaml
|
||||
pve:
|
||||
pve: # must match your actual Proxmox node name
|
||||
url: https://proxmox.host.or.ip:8006
|
||||
token: username@pam!Token ID
|
||||
secret: secret
|
||||
|
||||
@@ -16,4 +16,5 @@ widget:
|
||||
username: username
|
||||
password: password
|
||||
enableLeechProgress: true # optional, defaults to false
|
||||
enableLeechSize: true # optional, defaults to false
|
||||
```
|
||||
|
||||
28
docs/widgets/services/yourspotify.md
Normal file
28
docs/widgets/services/yourspotify.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Your Spotify
|
||||
description: Your Spotify Widget Configuration
|
||||
---
|
||||
|
||||
Learn more about [Your Spotify](https://github.com/Yooooomi/your_spotify).
|
||||
|
||||
Find your API key under `Settings > Account > Public token`, click `Generate` if not yet generated, copy key after
|
||||
`?token=`.
|
||||
|
||||
Allowed fields: `["songs", "time", "artists"]`.
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: yourspotify
|
||||
url: http://your-spotify-server.host.or.ip # if using lsio image, add /api/
|
||||
key: apikeyapikeyapikeyapikeyapikey
|
||||
interval: month # optional, defaults to week
|
||||
```
|
||||
|
||||
#### Interval
|
||||
|
||||
Allowed values for `interval`: `day`, `week`, `month`, `year`, `all`.
|
||||
|
||||
!!! note
|
||||
|
||||
`interval` is different from predefined intervals you see in `Your Spotify`'s UI.
|
||||
For example, `This week` in UI means _from the start of this week_, here `week` means _past 7 days_.
|
||||
@@ -176,6 +176,7 @@ nav:
|
||||
- widgets/services/wgeasy.md
|
||||
- widgets/services/whatsupdocker.md
|
||||
- widgets/services/xteve.md
|
||||
- widgets/services/yourspotify.md
|
||||
- widgets/services/zabbix.md
|
||||
- "Information Widgets":
|
||||
- widgets/info/index.md
|
||||
|
||||
10
package.json
10
package.json
@@ -17,8 +17,8 @@
|
||||
"compare-versions": "^6.1.1",
|
||||
"dockerode": "^4.0.7",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"gamedig": "^5.3.1",
|
||||
"i18next": "^24.2.3",
|
||||
"gamedig": "^5.3.2",
|
||||
"i18next": "^25.5.3",
|
||||
"ical.js": "^2.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-rpc-2.0": "^1.7.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"next-i18next": "^12.1.0",
|
||||
"ping": "^0.4.4",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"raw-body": "^3.0.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
@@ -44,13 +44,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "^15.2.4",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
562
pnpm-lock.yaml
generated
562
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1114,5 +1114,10 @@
|
||||
"num_success_latest": "Succeeding",
|
||||
"num_failure_latest": "Failing",
|
||||
"bytes_added_30": "Bytes Added"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Songs",
|
||||
"time": "Time",
|
||||
"artists": "Artists"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function QueueEntry({ title, activity, timeLeft, progress }) {
|
||||
export default function QueueEntry({ title, activity, timeLeft, progress, size }) {
|
||||
return (
|
||||
<div className="text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex">
|
||||
<div
|
||||
@@ -11,6 +11,7 @@ export default function QueueEntry({ title, activity, timeLeft, progress }) {
|
||||
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left">{title}</div>
|
||||
</div>
|
||||
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{size && `${size} - `}
|
||||
{timeLeft ? `${activity} - ${timeLeft}` : activity}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,6 +417,7 @@ function Home({ initialSettings }) {
|
||||
)}
|
||||
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
||||
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
||||
<meta name="color-scheme" content="dark light"></meta>
|
||||
</Head>
|
||||
|
||||
<Script src="/api/config/custom.js" />
|
||||
@@ -539,38 +540,48 @@ export default function Wrapper({ initialSettings, fallback }) {
|
||||
html.classList.add(desiredThemeClass);
|
||||
}
|
||||
|
||||
// Remove any previously applied inline styles
|
||||
body.style.backgroundImage = "";
|
||||
body.style.backgroundColor = "";
|
||||
body.style.backgroundAttachment = "";
|
||||
if (backgroundImage) {
|
||||
const safeBackgroundImage = backgroundImage.replace(/'/g, "\\'");
|
||||
body.style.backgroundImage = `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${safeBackgroundImage}')`;
|
||||
body.style.backgroundSize = "cover";
|
||||
body.style.backgroundPosition = "center";
|
||||
body.style.backgroundAttachment = "fixed";
|
||||
body.style.backgroundRepeat = "no-repeat";
|
||||
body.style.backgroundColor = "";
|
||||
} else {
|
||||
body.style.backgroundImage = "none";
|
||||
body.style.backgroundColor = "rgb(var(--bg-color))";
|
||||
body.style.backgroundSize = "";
|
||||
body.style.backgroundPosition = "";
|
||||
body.style.backgroundAttachment = "";
|
||||
body.style.backgroundRepeat = "";
|
||||
}
|
||||
|
||||
return () => {
|
||||
body.style.backgroundImage = "";
|
||||
body.style.backgroundColor = "";
|
||||
body.style.backgroundSize = "";
|
||||
body.style.backgroundPosition = "";
|
||||
body.style.backgroundAttachment = "";
|
||||
body.style.backgroundRepeat = "";
|
||||
};
|
||||
}, [backgroundImage, opacity, theme, color, initialSettings.color]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{backgroundImage && (
|
||||
<div
|
||||
id="background"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div id="page_wrapper" className="relative h-full">
|
||||
<div
|
||||
id="inner_wrapper"
|
||||
tabIndex="-1"
|
||||
className={classNames(
|
||||
"w-full h-full overflow-auto",
|
||||
backgroundBlur &&
|
||||
`backdrop-blur${initialSettings.background.blur?.length ? `-${initialSettings.background.blur}` : ""}`,
|
||||
backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,
|
||||
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
|
||||
)}
|
||||
>
|
||||
<Index initialSettings={initialSettings} fallback={fallback} />
|
||||
</div>
|
||||
<div id="page_wrapper" className="relative min-h-screen">
|
||||
<div
|
||||
id="inner_wrapper"
|
||||
tabIndex="-1"
|
||||
className={classNames(
|
||||
"w-full min-h-screen overflow-auto",
|
||||
backgroundBlur &&
|
||||
`backdrop-blur${initialSettings.background.blur?.length ? `-${initialSettings.background.blur}` : ""}`,
|
||||
backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,
|
||||
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
|
||||
)}
|
||||
>
|
||||
<Index initialSettings={initialSettings} fallback={fallback} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,18 +30,6 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: rgb(var(--bg-color));
|
||||
}
|
||||
|
||||
#background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: scroll;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -284,6 +284,7 @@ export function cleanServiceGroups(groups) {
|
||||
|
||||
// deluge, qbittorrent
|
||||
enableLeechProgress,
|
||||
enableLeechSize,
|
||||
|
||||
// diskstation
|
||||
volume,
|
||||
@@ -408,6 +409,9 @@ export function cleanServiceGroups(groups) {
|
||||
// wgeasy
|
||||
threshold,
|
||||
|
||||
// yourspotify
|
||||
interval,
|
||||
|
||||
// technitium
|
||||
range,
|
||||
|
||||
@@ -490,6 +494,7 @@ export function cleanServiceGroups(groups) {
|
||||
}
|
||||
if (["deluge", "qbittorrent"].includes(type)) {
|
||||
if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress);
|
||||
if (enableLeechSize !== undefined) widget.enableLeechSize = JSON.parse(enableLeechSize);
|
||||
}
|
||||
if (["opnsense", "pfsense"].includes(type)) {
|
||||
if (wan) widget.wan = wan;
|
||||
@@ -623,6 +628,11 @@ export function cleanServiceGroups(groups) {
|
||||
if (pool3) widget.pool3 = pool3;
|
||||
if (pool4) widget.pool4 = pool4;
|
||||
}
|
||||
if (type === "yourspotify") {
|
||||
if (interval !== undefined) {
|
||||
widget.interval = interval;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
return cleanedService;
|
||||
|
||||
@@ -24,10 +24,11 @@ function buildResponse(plans) {
|
||||
|
||||
plans.forEach((plan) => {
|
||||
const statuses = plan?.recentBackups?.status;
|
||||
// See https://github.com/garethgeorge/backrest/blob/4357295a17cb2e71639473c9929a060c4dd1b624/proto/v1/operations.proto#L78-L87
|
||||
if (Array.isArray(statuses) && statuses.length > 0) {
|
||||
if (statuses[0] === "STATUS_SUCCESS") {
|
||||
numSuccessLatest++;
|
||||
} else {
|
||||
} else if (statuses[0] === "STATUS_ERROR") {
|
||||
numFailureLatest++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ const components = {
|
||||
wgeasy: dynamic(() => import("./wgeasy/component")),
|
||||
whatsupdocker: dynamic(() => import("./whatsupdocker/component")),
|
||||
xteve: dynamic(() => import("./xteve/component")),
|
||||
yourspotify: dynamic(() => import("./yourspotify/component")),
|
||||
zabbix: dynamic(() => import("./zabbix/component")),
|
||||
};
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ export default function Component({ service }) {
|
||||
timeLeft={t("common.duration", { value: queueEntry.eta })}
|
||||
title={queueEntry.name}
|
||||
activity={queueEntry.state}
|
||||
size={
|
||||
widget?.enableLeechSize
|
||||
? t("common.bbytes", { value: queueEntry.size, maximumFractionDigits: 1 })
|
||||
: undefined
|
||||
}
|
||||
key={`${queueEntry.name}-${queueEntry.amount_left}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function Component({ service }) {
|
||||
let status;
|
||||
let uptime = 0;
|
||||
let logIndex = 0;
|
||||
const hasLogs = Array.isArray(monitor.logs) && monitor.logs.length > 0;
|
||||
|
||||
switch (monitor.status) {
|
||||
case 0:
|
||||
@@ -62,7 +63,7 @@ export default function Component({ service }) {
|
||||
break;
|
||||
case 2:
|
||||
status = t("uptimerobot.up");
|
||||
uptime = t("common.duration", { value: monitor.logs[0].duration });
|
||||
uptime = t("common.duration", { value: hasLogs ? monitor.logs[0].duration : 0 });
|
||||
logIndex = 1;
|
||||
break;
|
||||
case 8:
|
||||
@@ -76,14 +77,14 @@ export default function Component({ service }) {
|
||||
break;
|
||||
}
|
||||
|
||||
const lastDown = new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString();
|
||||
const downDuration = t("common.duration", { value: monitor.logs[logIndex].duration });
|
||||
const hideDown = logIndex === 1 && monitor.logs[logIndex].type !== 1;
|
||||
const lastDown = hasLogs ? new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString() : "";
|
||||
const downDuration = t("common.duration", { value: hasLogs ? monitor.logs[logIndex].duration : 0 });
|
||||
const hideDown = !hasLogs || (logIndex === 1 && monitor.logs[logIndex].type !== 1);
|
||||
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="uptimerobot.status" value={status} />
|
||||
<Block label="uptimerobot.uptime" value={uptime} />
|
||||
{hasLogs && <Block label="uptimerobot.uptime" value={uptime} />}
|
||||
{!hideDown && <Block label="uptimerobot.lastDown" value={lastDown} />}
|
||||
{!hideDown && <Block label="uptimerobot.downDuration" value={downDuration} />}
|
||||
</Container>
|
||||
|
||||
@@ -141,6 +141,7 @@ import watchtower from "./watchtower/widget";
|
||||
import wgeasy from "./wgeasy/widget";
|
||||
import whatsupdocker from "./whatsupdocker/widget";
|
||||
import xteve from "./xteve/widget";
|
||||
import yourspotify from "./yourspotify/widget";
|
||||
import zabbix from "./zabbix/widget";
|
||||
|
||||
const widgets = {
|
||||
@@ -291,6 +292,7 @@ const widgets = {
|
||||
wgeasy,
|
||||
whatsupdocker,
|
||||
xteve,
|
||||
yourspotify,
|
||||
zabbix,
|
||||
};
|
||||
|
||||
|
||||
76
src/widgets/yourspotify/component.jsx
Normal file
76
src/widgets/yourspotify/component.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import Block from "components/services/widget/block";
|
||||
import Container from "components/services/widget/container";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
function getStartDate(interval) {
|
||||
const d = new Date();
|
||||
switch (interval) {
|
||||
case "day":
|
||||
d.setDate(d.getDate() - 1);
|
||||
break;
|
||||
case "week":
|
||||
d.setDate(d.getDate() - 7);
|
||||
break;
|
||||
case "month":
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
break;
|
||||
case "year":
|
||||
d.setFullYear(d.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
|
||||
const interval = widget?.interval || "week";
|
||||
|
||||
const date = useMemo(() => {
|
||||
return interval === "all" ? "2006-04-23T00:00:00.000Z" : getStartDate(interval);
|
||||
}, [interval]);
|
||||
|
||||
const params = {
|
||||
timeSplit: "all",
|
||||
start: date,
|
||||
};
|
||||
|
||||
const { data: songsListened, error: songsError } = useWidgetAPI(widget, "songs", params);
|
||||
const { data: timeListened, error: timeError } = useWidgetAPI(widget, "time", params);
|
||||
const { data: artistsListened, error: artistsError } = useWidgetAPI(widget, "artists", params);
|
||||
|
||||
if (songsError || timeError || artistsError) {
|
||||
return <Container service={service} error={songsError ?? timeError ?? artistsError} />;
|
||||
}
|
||||
|
||||
if (isNaN(songsListened) || isNaN(timeListened) || isNaN(artistsListened)) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="yourspotify.songs" />
|
||||
<Block label="yourspotify.time" />
|
||||
<Block label="yourspotify.artists" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="yourspotify.songs" value={t("common.number", { value: songsListened })} />
|
||||
|
||||
<Block
|
||||
label="yourspotify.time"
|
||||
value={t(
|
||||
timeListened > 0 ? "common.duration" : "common.number", // Display 0 if duration is 0
|
||||
{
|
||||
value: timeListened / 1000,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<Block label="yourspotify.artists" value={t("common.number", { value: artistsListened })} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
27
src/widgets/yourspotify/widget.js
Normal file
27
src/widgets/yourspotify/widget.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { asJson } from "utils/proxy/api-helpers";
|
||||
import genericProxyHandler from "utils/proxy/handlers/generic";
|
||||
|
||||
const widget = {
|
||||
api: "{url}/spotify/{endpoint}?token={key}",
|
||||
proxyHandler: genericProxyHandler,
|
||||
|
||||
mappings: {
|
||||
songs: {
|
||||
endpoint: "songs_per",
|
||||
params: ["start", "timeSplit"],
|
||||
map: (data) => asJson(data)[0]?.count || 0,
|
||||
},
|
||||
time: {
|
||||
endpoint: "time_per",
|
||||
params: ["start", "timeSplit"],
|
||||
map: (data) => asJson(data)[0]?.count || 0,
|
||||
},
|
||||
artists: {
|
||||
endpoint: "different_artists_per",
|
||||
params: ["start", "timeSplit"],
|
||||
map: (data) => asJson(data)[0]?.artists?.length || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default widget;
|
||||
Reference in New Issue
Block a user