Add Outlook unread-mail widget with device-code login
- outlook-auth.js: one-time device-code flow against Microsoft login; stores the refresh token in the outlook-tokens compose volume - update-outlook.js: per-minute cron fetching the unread count and the latest unread messages via Microsoft Graph; caches access tokens and persists rotated refresh tokens - Widget is hidden until the mailbox is actually authorized, so it stays out of sight while (admin) consent is pending - New external card for Outlook (Office 365) with the official icon - compose: pass OUTLOOK_CLIENT_ID/OUTLOOK_TENANT/GITEA_TOKEN from .env - README: Entra ID app registration steps and the tenant-ID hint for directories not resolvable via the organizations endpoint
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
api/node_modules
|
api/node_modules
|
||||||
|
api/tokens
|
||||||
*.tgz
|
*.tgz
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
# Backend scripts (node_modules included so the build works offline).
|
# Backend scripts (node_modules included so the build works offline).
|
||||||
COPY api /opt/dashboard
|
COPY api /opt/dashboard
|
||||||
|
|
||||||
# Cron refreshes the data feed every 15 minutes and health checks every minute.
|
# Cron: data feed every 15 min, health checks + Outlook unread every minute.
|
||||||
RUN printf '*/15 * * * * cd /opt/dashboard && node update-data.js\n* * * * * cd /opt/dashboard && node update-health.js\n' > /etc/crontabs/root
|
RUN printf '*/15 * * * * cd /opt/dashboard && node update-data.js\n* * * * * cd /opt/dashboard && node update-health.js\n* * * * * cd /opt/dashboard && node update-outlook.js\n' > /etc/crontabs/root
|
||||||
|
|
||||||
# Entrypoint: run updater once, start API proxy + cron, then nginx.
|
# Entrypoint: run updater once, start API proxy + cron, then nginx.
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|||||||
@@ -57,6 +57,40 @@ automatically. Non-secret settings go directly in `docker-compose.yml`:
|
|||||||
| `GITEA_URL` / `GITLAB_URL` / `NETBIRD_URL` / `OPENWEBUI_URL` | Override the default service URLs |
|
| `GITEA_URL` / `GITLAB_URL` / `NETBIRD_URL` / `OPENWEBUI_URL` | Override the default service URLs |
|
||||||
| `GPU_STATS_URL` | GPU stats upstream (default: the `gpu-stats` compose service) |
|
| `GPU_STATS_URL` | GPU stats upstream (default: the `gpu-stats` compose service) |
|
||||||
| `OLLAMA_URL` | Ollama instance for loaded-model stats (default: `http://100.103.83.12:11435`) |
|
| `OLLAMA_URL` | Ollama instance for loaded-model stats (default: `http://100.103.83.12:11435`) |
|
||||||
|
| `OUTLOOK_CLIENT_ID` | Entra ID app client ID for the unread-mail widget (**set in `.env`**) |
|
||||||
|
| `OUTLOOK_TENANT` | Azure tenant (default: `organizations`; set to your Directory (tenant) ID if the generic endpoint returns AADSTS50059) |
|
||||||
|
|
||||||
|
### Outlook unread mail
|
||||||
|
|
||||||
|
The Outlook widget (and the unread count) uses the Microsoft Graph API with a
|
||||||
|
one-time device login. Setup:
|
||||||
|
|
||||||
|
1. **Register an app** in Microsoft Entra ID (portal.azure.com → Entra ID →
|
||||||
|
App registrations → New registration): any name (e.g. "Homelab Dashboard"),
|
||||||
|
account type "Accounts in this organizational directory only", no redirect URI.
|
||||||
|
2. In the app: **Authentication → Allow public client flows → Yes**, then
|
||||||
|
**API permissions → Add → Microsoft Graph → Delegated → Mail.Read**.
|
||||||
|
3. Copy the **Application (client) ID** — from App registrations → your app →
|
||||||
|
Overview. Beware: this is *not* the "Directory (tenant) ID" and not the
|
||||||
|
"Object ID". A valid-format but wrong GUID produces AADSTS50059
|
||||||
|
("No tenant-identifying information found"). Put it in `.env` as
|
||||||
|
`OUTLOOK_CLIENT_ID=`.
|
||||||
|
4. Set `OUTLOOK_TENANT` in `.env` to your **Directory (tenant) ID**. Some
|
||||||
|
directories are not resolvable through the generic `organizations`
|
||||||
|
endpoint (also AADSTS50059 with a valid client ID) — the tenant-specific
|
||||||
|
endpoint always works.
|
||||||
|
4. `docker compose up -d --build`
|
||||||
|
5. Log in once:
|
||||||
|
```bash
|
||||||
|
docker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.js
|
||||||
|
```
|
||||||
|
Open the printed URL, enter the code and sign in with your work account.
|
||||||
|
The refresh token is persisted in the `outlook-tokens` volume.
|
||||||
|
|
||||||
|
`api/update-outlook.js` then refreshes unread mail every minute (caching the
|
||||||
|
access token, and re-running `outlook-auth.js` is only needed if login is
|
||||||
|
revoked). If your tenant requires admin consent, ask your admin to grant it
|
||||||
|
for `Mail.Read`.
|
||||||
|
|
||||||
### GPU stats service
|
### GPU stats service
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One-time device-code login for the Outlook unread-mail widget.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// 1. Set OUTLOOK_CLIENT_ID (Entra ID app registration — see README)
|
||||||
|
// 2. docker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.js
|
||||||
|
// (or locally: OUTLOOK_CLIENT_ID=... node api/outlook-auth.js)
|
||||||
|
// 3. Open the printed URL, enter the code, sign in with your work account
|
||||||
|
//
|
||||||
|
// Stores the refresh token in a token file used by update-outlook.js.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const https = require("https");
|
||||||
|
const { URL } = require("url");
|
||||||
|
|
||||||
|
const CLIENT_ID = (process.env.OUTLOOK_CLIENT_ID || "").trim();
|
||||||
|
const TENANT = (process.env.OUTLOOK_TENANT || "organizations").trim();
|
||||||
|
const SCOPE = "https://graph.microsoft.com/Mail.Read offline_access";
|
||||||
|
|
||||||
|
// Hints for the most common Entra ID app-registration mistakes.
|
||||||
|
const AUTH_HINTS = {
|
||||||
|
50059:
|
||||||
|
"The client ID does not match any registered application. Copy the " +
|
||||||
|
"'Application (client) ID' from App registrations > your app > Overview " +
|
||||||
|
"(NOT the 'Directory (tenant) ID' or 'Object ID'), check for stray " +
|
||||||
|
"whitespace/quotes, and verify the app exists in your directory. If the " +
|
||||||
|
"client ID is correct, your directory may not be resolvable via the " +
|
||||||
|
"generic 'organizations' endpoint — set OUTLOOK_TENANT to your " +
|
||||||
|
"Directory (tenant) ID.",
|
||||||
|
700038: "The client ID is not a valid identifier — check for typos or truncation.",
|
||||||
|
700016:
|
||||||
|
"Application not found in the tenant. The app registration may be in a " +
|
||||||
|
"different directory, or the client ID is wrong.",
|
||||||
|
7000215:
|
||||||
|
"The app is not configured as a public client. In the app registration: " +
|
||||||
|
"Authentication > Allow public client flows > Yes.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function aadstsHint(body) {
|
||||||
|
try {
|
||||||
|
const codes = JSON.parse(body)?.error_codes || [];
|
||||||
|
for (const c of codes) {
|
||||||
|
if (AUTH_HINTS[c]) return `\nHint (AADSTS${c}): ${AUTH_HINTS[c]}`;
|
||||||
|
}
|
||||||
|
const m = /AADSTS(\d+)/.exec(String(body));
|
||||||
|
if (m && AUTH_HINTS[m[1]]) return `\nHint (AADSTS${m[1]}): ${AUTH_HINTS[m[1]]}`;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenFilePath() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.OUTLOOK_TOKEN_FILE,
|
||||||
|
"/opt/dashboard/tokens/outlook.json",
|
||||||
|
path.join(__dirname, "tokens", "outlook.json"),
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path.dirname(p))) return p;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallback = path.join(__dirname, "tokens", "outlook.json");
|
||||||
|
fs.mkdirSync(path.dirname(fallback), { recursive: true });
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const req = https.request(
|
||||||
|
u,
|
||||||
|
{
|
||||||
|
method: options.method || "GET",
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let body = "";
|
||||||
|
res.setEncoding("utf8");
|
||||||
|
res.on("data", (chunk) => (body += chunk));
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Timeout"));
|
||||||
|
});
|
||||||
|
if (options.body) req.write(options.body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function form(data) {
|
||||||
|
return new URLSearchParams(data).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!CLIENT_ID) {
|
||||||
|
console.error("OUTLOOK_CLIENT_ID is not set.");
|
||||||
|
console.error("Register an app in Entra ID (see README: Outlook unread mail) and put its client ID in .env.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = tokenFilePath();
|
||||||
|
console.log(`Token file: ${file}\n`);
|
||||||
|
|
||||||
|
// 1. Request a device code
|
||||||
|
const dcRes = await request(
|
||||||
|
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({ client_id: CLIENT_ID, scope: SCOPE }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (dcRes.status !== 200) {
|
||||||
|
console.error(`Device code request failed (${dcRes.status}):`, dcRes.body);
|
||||||
|
console.error(aadstsHint(dcRes.body));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dc = JSON.parse(dcRes.body);
|
||||||
|
|
||||||
|
console.log("----------------------------------------------------------------");
|
||||||
|
console.log(` 1. Open: ${dc.verification_uri}`);
|
||||||
|
console.log(` 2. Code: ${dc.user_code}`);
|
||||||
|
console.log(` 3. Sign in with your work account and approve the consent prompt`);
|
||||||
|
console.log("----------------------------------------------------------------\n");
|
||||||
|
console.log("Waiting for login…");
|
||||||
|
|
||||||
|
// 2. Poll for the token
|
||||||
|
const interval = (dc.interval || 5) * 1000;
|
||||||
|
const deadline = Date.now() + (dc.expires_in || 900) * 1000;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
const res = await request(
|
||||||
|
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: dc.device_code,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
|
||||||
|
if (res.status === 200 && data.access_token) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_on: Date.now() + (data.expires_in || 3600) * 1000,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log("\nLogin successful — token stored. The unread-mail widget will pick it up within a minute.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error === "authorization_pending") continue;
|
||||||
|
if (data.error === "slow_down") {
|
||||||
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.error(`\nLogin failed: ${data.error} — ${data.error_description || ""}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("\nDevice code expired — run the script again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetches unread mail via Microsoft Graph and writes outlook.json.
|
||||||
|
// Runs every minute via cron. Uses (and caches) the token created by
|
||||||
|
// outlook-auth.js; refreshes it when expired and persists rotated
|
||||||
|
// refresh tokens.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const https = require("https");
|
||||||
|
const { URL } = require("url");
|
||||||
|
|
||||||
|
const OUT_FILE = process.env.OUT_FILE || "/usr/share/nginx/html/outlook.json";
|
||||||
|
const CLIENT_ID = (process.env.OUTLOOK_CLIENT_ID || "").trim();
|
||||||
|
const TENANT = (process.env.OUTLOOK_TENANT || "organizations").trim();
|
||||||
|
const SCOPE = "https://graph.microsoft.com/Mail.Read offline_access";
|
||||||
|
const TOP = parseInt(process.env.OUTLOOK_TOP || "5", 10);
|
||||||
|
|
||||||
|
function writeOut(payload) {
|
||||||
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenFilePath() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.OUTLOOK_TOKEN_FILE,
|
||||||
|
"/opt/dashboard/tokens/outlook.json",
|
||||||
|
path.join(__dirname, "tokens", "outlook.json"),
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path.dirname(p))) return p;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const req = https.request(
|
||||||
|
u,
|
||||||
|
{
|
||||||
|
method: options.method || "GET",
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let body = "";
|
||||||
|
res.setEncoding("utf8");
|
||||||
|
res.on("data", (chunk) => (body += chunk));
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Timeout"));
|
||||||
|
});
|
||||||
|
if (options.body) req.write(options.body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, headers = {}) {
|
||||||
|
const res = await request(url, { headers });
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
const detail = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(res.body)?.error?.message || res.body;
|
||||||
|
} catch {
|
||||||
|
return res.body;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
throw new Error(`HTTP ${res.status}: ${String(detail).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(res.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(tokenFile) {
|
||||||
|
const token = JSON.parse(fs.readFileSync(tokenFile, "utf8"));
|
||||||
|
|
||||||
|
// Reuse the cached access token while it is valid (with 60 s slack).
|
||||||
|
if (token.access_token && token.expires_on && token.expires_on > Date.now() + 60000) {
|
||||||
|
return token.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: token.refresh_token,
|
||||||
|
scope: SCOPE,
|
||||||
|
}).toString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
if (res.status !== 200 || !data.access_token) {
|
||||||
|
throw new Error(`Token refresh failed (${data.error || res.status}) — re-run outlook-auth.js`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the (possibly rotated) refresh token + cached access token.
|
||||||
|
fs.writeFileSync(
|
||||||
|
tokenFile,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
refresh_token: data.refresh_token || token.refresh_token,
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_on: Date.now() + (data.expires_in || 3600) * 1000,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Not configured: report that so the widget can show a helpful hint.
|
||||||
|
if (!CLIENT_ID) {
|
||||||
|
writeOut({ updatedAt: new Date().toISOString(), enabled: false, unreadCount: 0, messages: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenFile = tokenFilePath();
|
||||||
|
if (!tokenFile || !fs.existsSync(tokenFile)) {
|
||||||
|
writeOut({
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
enabled: true,
|
||||||
|
error: "Not connected — run outlook-auth.js (see README)",
|
||||||
|
unreadCount: 0,
|
||||||
|
messages: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken(tokenFile);
|
||||||
|
const auth = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
const [me, folder, messages] = await Promise.all([
|
||||||
|
fetchJson("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", auth),
|
||||||
|
fetchJson("https://graph.microsoft.com/v1.0/me/mailFolders/inbox?$select=unreadItemCount", auth),
|
||||||
|
fetchJson(
|
||||||
|
`https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages` +
|
||||||
|
`?$filter=${encodeURIComponent("isRead eq false")}` +
|
||||||
|
`&$orderby=${encodeURIComponent("receivedDateTime desc")}` +
|
||||||
|
`&$top=${TOP}&$select=subject,from,receivedDateTime`,
|
||||||
|
auth
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
writeOut({
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
enabled: true,
|
||||||
|
account: me.mail || me.userPrincipalName || "",
|
||||||
|
unreadCount: folder.unreadItemCount ?? 0,
|
||||||
|
messages: (messages.value || []).map((m) => ({
|
||||||
|
from: m.from?.emailAddress?.name || m.from?.emailAddress?.address || "unknown",
|
||||||
|
subject: m.subject || "(no subject)",
|
||||||
|
received: m.receivedDateTime,
|
||||||
|
})),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
writeOut({
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
enabled: true,
|
||||||
|
error: e.message,
|
||||||
|
unreadCount: 0,
|
||||||
|
messages: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+11
-1
@@ -13,9 +13,13 @@ services:
|
|||||||
- TZ=Europe/Vienna
|
- TZ=Europe/Vienna
|
||||||
# Secrets live in .env (gitignored) — compose reads it automatically.
|
# Secrets live in .env (gitignored) — compose reads it automatically.
|
||||||
- OPENWEBUI_TOKEN=${OPENWEBUI_TOKEN:-}
|
- OPENWEBUI_TOKEN=${OPENWEBUI_TOKEN:-}
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||||
|
# Outlook unread-mail widget (see README for the Entra ID app registration
|
||||||
|
# and the one-time device login).
|
||||||
|
- OUTLOOK_CLIENT_ID=${OUTLOOK_CLIENT_ID:-}
|
||||||
|
- OUTLOOK_TENANT=${OUTLOOK_TENANT:-organizations}
|
||||||
# Optional API tokens for live stats / git integration.
|
# Optional API tokens for live stats / git integration.
|
||||||
# Uncomment and fill in to enable private git stats.
|
# Uncomment and fill in to enable private git stats.
|
||||||
# - GITEA_TOKEN=
|
|
||||||
# - GITLAB_TOKEN=
|
# - GITLAB_TOKEN=
|
||||||
# - NETBIRD_TOKEN=
|
# - NETBIRD_TOKEN=
|
||||||
# GPU stats upstream; defaults to the gpu-stats service below.
|
# GPU stats upstream; defaults to the gpu-stats service below.
|
||||||
@@ -25,6 +29,9 @@ services:
|
|||||||
# - GPU_STATS_URL=http://gpu-stats:9101/stats
|
# - GPU_STATS_URL=http://gpu-stats:9101/stats
|
||||||
# Ollama instance for the loaded-model stats in the GPU widget.
|
# Ollama instance for the loaded-model stats in the GPU widget.
|
||||||
# - OLLAMA_URL=http://100.103.83.12:11435
|
# - OLLAMA_URL=http://100.103.83.12:11435
|
||||||
|
volumes:
|
||||||
|
# Persists the Outlook OAuth refresh token across container updates.
|
||||||
|
- outlook-tokens:/opt/dashboard/tokens
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -53,3 +60,6 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "1m"
|
max-size: "1m"
|
||||||
max-file: "2"
|
max-file: "2"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
outlook-tokens:
|
||||||
|
|||||||
@@ -16,5 +16,9 @@ node /opt/dashboard/server.js &
|
|||||||
# First health-check sweep in the background; cron re-runs it every minute.
|
# First health-check sweep in the background; cron re-runs it every minute.
|
||||||
( cd /opt/dashboard && node update-health.js ) &
|
( cd /opt/dashboard && node update-health.js ) &
|
||||||
|
|
||||||
|
# Outlook unread mail (no-op until OUTLOOK_CLIENT_ID is set and the device
|
||||||
|
# login has been completed); cron re-runs it every minute.
|
||||||
|
( cd /opt/dashboard && node update-outlook.js ) &
|
||||||
|
|
||||||
# Start nginx in the foreground.
|
# Start nginx in the foreground.
|
||||||
exec nginx -g "daemon off;"
|
exec nginx -g "daemon off;"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
+12
-3
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>Homelab Dashboard</title>
|
<title>GG36</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@@ -21,8 +21,7 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="logo-dot" aria-hidden="true"></div>
|
<div class="logo-dot" aria-hidden="true"></div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<h1>Homelab</h1>
|
<h1>GG36</h1>
|
||||||
<p class="subtitle">All your services, one place.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
@@ -62,6 +61,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="widget widget-outlook" id="widget-outlook" hidden>
|
||||||
|
<div class="widget-head">
|
||||||
|
<div class="widget-icon" aria-hidden="true">✉️</div>
|
||||||
|
<span class="widget-title">Outlook</span>
|
||||||
|
</div>
|
||||||
|
<div class="widget-body" id="outlook-mail">
|
||||||
|
<p class="widget-loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="widget widget-git" id="widget-git">
|
<article class="widget widget-git" id="widget-git">
|
||||||
<div class="widget-head">
|
<div class="widget-head">
|
||||||
<div class="widget-icon" aria-hidden="true">🐙</div>
|
<div class="widget-icon" aria-hidden="true">🐙</div>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ server {
|
|||||||
add_header Cache-Control "no-cache" always;
|
add_header Cache-Control "no-cache" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Outlook unread mail; refreshed every minute by cron.
|
||||||
|
location = /outlook.json {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
# Open WebUI chat proxy.
|
# Open WebUI chat proxy.
|
||||||
location /api/chat {
|
location /api/chat {
|
||||||
proxy_pass http://127.0.0.1:3001;
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const SERVICES = [
|
|||||||
{ section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: '<img class="card-logo" src="icons/netbird.svg" alt="" />', liveStatus: "netbird" },
|
{ section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: '<img class="card-logo" src="icons/netbird.svg" alt="" />', liveStatus: "netbird" },
|
||||||
|
|
||||||
// ----- External -----
|
// ----- External -----
|
||||||
|
{ section: "external", name: "Outlook (Office 365)", url: "https://outlook.office.com/mail/", cat: "external", icon: '<img class="card-logo" src="icons/outlook.ico" alt="" />' },
|
||||||
{ section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '<img class="card-logo" src="icons/gitlab.svg" alt="" />' },
|
{ section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '<img class="card-logo" src="icons/gitlab.svg" alt="" />' },
|
||||||
{ section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '<img class="card-logo" src="icons/odoo.svg" alt="" />' },
|
{ section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '<img class="card-logo" src="icons/odoo.svg" alt="" />' },
|
||||||
];
|
];
|
||||||
@@ -603,6 +604,68 @@ async function loadHealth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outlook unread mail (via /outlook.json, refreshed every minute server-side)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function relTime(iso) {
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return "";
|
||||||
|
const mins = Math.floor((Date.now() - t) / 60000);
|
||||||
|
if (mins < 1) return "now";
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
return `${Math.floor(hours / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutlookWidget(o) {
|
||||||
|
const widget = document.getElementById("widget-outlook");
|
||||||
|
const el = document.getElementById("outlook-mail");
|
||||||
|
if (!widget || !el) return;
|
||||||
|
|
||||||
|
// Show the widget only once the mailbox is actually authorized; hide it
|
||||||
|
// while not configured, waiting for (admin) consent or on auth errors.
|
||||||
|
const authorized = Boolean(o && o.enabled && !o.error);
|
||||||
|
widget.hidden = !authorized;
|
||||||
|
if (!authorized) return;
|
||||||
|
|
||||||
|
const account = o.account ? `<div class="mail-account">${escapeHtml(o.account)}</div>` : "";
|
||||||
|
|
||||||
|
const badge = `<span class="git-badge mail">${o.unreadCount ?? 0} unread</span>`;
|
||||||
|
const msgs = o.messages || [];
|
||||||
|
|
||||||
|
if (msgs.length === 0) {
|
||||||
|
el.innerHTML = `${account}${badge}<p class="widget-empty">No unread mail.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML =
|
||||||
|
`${account}${badge}` +
|
||||||
|
msgs
|
||||||
|
.map(
|
||||||
|
(m) => `
|
||||||
|
<div class="mail-row">
|
||||||
|
<div class="mail-head">
|
||||||
|
<span class="mail-from" title="${escapeHtml(m.from || "")}">${escapeHtml(m.from || "unknown")}</span>
|
||||||
|
<span class="mail-time">${escapeHtml(relTime(m.received))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mail-subject" title="${escapeHtml(m.subject || "")}">${escapeHtml(m.subject || "(no subject)")}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOutlook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("outlook.json", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
renderOutlookWidget(await res.json());
|
||||||
|
} catch {
|
||||||
|
// keep the current content; the next poll will retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Search / filter
|
// Search / filter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -664,3 +727,5 @@ loadOllamaStats();
|
|||||||
setInterval(loadOllamaStats, 5000); // live Ollama loaded models every 5 s
|
setInterval(loadOllamaStats, 5000); // live Ollama loaded models every 5 s
|
||||||
loadHealth();
|
loadHealth();
|
||||||
setInterval(loadHealth, 30000); // service health badges every 30 s
|
setInterval(loadHealth, 30000); // service health badges every 30 s
|
||||||
|
loadOutlook();
|
||||||
|
setInterval(loadOutlook, 60000); // unread mail every 60 s
|
||||||
|
|||||||
@@ -1014,6 +1014,60 @@ main#app {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* outlook unread mail widget */
|
||||||
|
.mail-account {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-badge.mail {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-from {
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-time {
|
||||||
|
color: var(--muted-2);
|
||||||
|
flex: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-subject {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- empty / footer ---------- */
|
/* ---------- empty / footer ---------- */
|
||||||
|
|
||||||
/* ---------- responsive ---------- */
|
/* ---------- responsive ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user