From de5c39dddf8954348e58d4b31d48e5677e691af3 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 29 Sep 2026 23:09:56 +0200 Subject: [PATCH] Add an osTicket integration: list open tickets from its database osTicket's own REST API only supports creating tickets, not listing or reading them, so this reads osTicket's MySQL/MariaDB database directly with a read-only user instead - the only integration in this app that isn't a REST API. Joins the ticket, status, priority, department, staff, team, and user tables, filtered to tickets in the "open" state (status names are customizable per install, but that state flag isn't). Surfaces per-ticket subject, priority, department, assignee, requester, and osTicket's own overdue/awaiting-reply flags, plus a page at /osticket and a Dashboard widget with open/overdue/awaiting-reply counts. Not verified against a live instance: unlike the HTTP-based integrations, there was no way to fake a MySQL server to test against in this environment, so the query is built from osTicket's published schema but has never actually run against a real database. See INTEGRATIONS.md for the read-only grant needed and further caveats. Co-Authored-By: Claude Sonnet 5 --- INTEGRATIONS.md | 44 ++++ README.md | 27 +- ROLES.md | 1 + package-lock.json | 110 ++++++++ server/package.json | 1 + server/src/db/schema.ts | 1 + server/src/integrations/fieldSchemas.ts | 14 + server/src/integrations/osticket/adapter.ts | 162 ++++++++++++ server/src/integrations/registry.ts | 3 + server/src/routes/integrations.ts | 42 +++ web/src/App.tsx | 2 + web/src/api/client.ts | 31 ++- web/src/components/CommandPalette.tsx | 1 + web/src/components/IntegrationEditForm.tsx | 1 + web/src/components/IntegrationForm.tsx | 1 + web/src/layout/AppShell.tsx | 2 + web/src/pages/Dashboard.tsx | 53 +++- web/src/pages/Integrations.tsx | 1 + web/src/pages/OsTicket.tsx | 268 ++++++++++++++++++++ web/src/pages/settings/BadgeSettings.tsx | 1 + 20 files changed, 754 insertions(+), 12 deletions(-) create mode 100644 server/src/integrations/osticket/adapter.ts create mode 100644 web/src/pages/OsTicket.tsx diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md index 2311150..8d7793f 100644 --- a/INTEGRATIONS.md +++ b/INTEGRATIONS.md @@ -104,6 +104,50 @@ the dashboard views themselves load fine. it against a real Proxmox Backup Server yet. If a datastore comes back empty or with the wrong fields, tell us what your instance actually returned and we'll adjust. +### osTicket + +- **Config fields:** Database host, Database port (optional, default 3306), Database name, Database username, + Database password, Table prefix (optional, default `ost_` — only needed if you changed it at install time) +- **Auth:** a plain MySQL/MariaDB connection (host/port/database/username/password) — not HTTP, and not osTicket's + own API. +- **Why this one reads the database directly:** osTicket's official REST API only supports **creating** tickets + (`POST /api/tickets.json`) — there is no documented endpoint to list or read existing ones (osTicket's own + developer docs say as much: *"For now, only ticket creation is supported..."*). Listing tickets therefore means + reading osTicket's own MySQL/MariaDB database directly, the same way osTicket's own admin panel does internally. + This makes it the only integration in this app that isn't a REST API. +- **Actions used:** read-only — a single `SELECT` joining the ticket, status, priority, department, staff, team, + and requester tables for every ticket whose status is in the "open" state. Nothing here can create, update, or + close a ticket. +- **Required access:** a MySQL/MariaDB user with **read-only (`SELECT`) access to the osTicket database only** — + never reuse osTicket's own application database user, which has full read/write access. Create one with + something like: + ```sql + CREATE USER 'homelab_manager'@'%' IDENTIFIED BY 'a-strong-password'; + GRANT SELECT ON osticket.* TO 'homelab_manager'@'%'; + FLUSH PRIVILEGES; + ``` + (narrow the host part — `'%'` — to this app's actual server address if your MySQL/MariaDB setup allows it, and + make sure the database's own network/firewall rules allow that connection in the first place; this integration + connects over plain TCP, unencrypted, so it's meant for a same-host or same-LAN database, not one reachable over + the internet). +- **What you get:** every currently-open ticket's number, subject, status, priority, department, assigned staff + member or team (or "Unassigned"), requester name/email, source, created/last-activity/due dates, and two flags + osTicket already tracks natively — **overdue** and **awaiting our reply** (i.e. the customer replied last and + nobody on staff has answered yet) — which are the two things most worth a glance on a dashboard. +- **A reliability note on subject/priority specifically:** those two fields aren't columns on osTicket's main + ticket table — osTicket normalizes them into its dynamic custom-fields system, and reads them back here from + `ost_ticket__cdata`, a cache table osTicket's own admin panel also uses for ticket lists (faster than joining the + generic form-fields tables). osTicket's own GitHub issue tracker documents that cache occasionally going stale or + briefly missing right after a custom-field change; this integration's query is written so a ticket with no + matching cache row still shows up in the list, just with an empty subject/priority instead of being silently + dropped. +- **Not verified against a live instance** — built from osTicket's own published database schema and developer + docs (table/column names, the ticket status `state` classification, and the cdata-cache mechanism all + cross-checked there), but this could not be run against a real osTicket database in this environment either (no + MySQL/MariaDB server was available to test against). If a query comes back empty, errors on a missing column, or + a field looks wrong, tell us what happened and we'll adjust — this one has had less real-world exposure than + every other integration listed here. + ### phpIPAM - **Config fields:** phpIPAM URL, API app ID, App token diff --git a/README.md b/README.md index 5399abd..2a40009 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Repository: `git@10.200.5.13:bobban/Homelab-manager.git` ([gitea.labsconnect.se/bobban/Homelab-manager](https://gitea.labsconnect.se/bobban/Homelab-manager) externally). A single dashboard for a homelab: Proxmox, Synology DSM, Semaphore, Tailscale, -Gitea, Dockhand/Docker, Uptime Kuma, and Proxmox Backup Server status and basic -actions, plus DNS record +Gitea, Dockhand/Docker, Uptime Kuma, Proxmox Backup Server, and osTicket status +and basic actions, plus DNS record management, an IP address inventory (IPAM), and a secret-expiry tracker (ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking across Debian/Raspbian hosts (ported from @@ -40,9 +40,11 @@ All modules from the original plan are built: many are matched to one of your servers. The Proxmox Backup Server widget shows the datastore count and how many stored snapshots have failed verification or were never verified. + The osTicket widget shows how many tickets are open, overdue, and awaiting + a staff reply. - **Diagnostic Log** (admin-only) — every call this app makes to a DNS provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea, - Dockhand, Uptime Kuma, Proxmox Backup Server), success or failure, with latency and the error message if it + Dockhand, Uptime Kuma, Proxmox Backup Server, osTicket), success or failure, with latency and the error message if it failed — the last 500 calls, filterable by source/result, for troubleshooting connectivity issues (ported from Sloth Manager's provider-diagnostics log, generalized to cover every integration this app @@ -83,9 +85,9 @@ All modules from the original plan are built: link options") — it stays visible regardless once a server actually is linked, so unlinking is always reachable. - **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**, - **Docker**, **Uptime Kuma**, and **Proxmox Backup Server** each get their - own top-level page (backed by the matching integration) instead of living - inside a shared Integrations browsing view: + **Docker**, **Uptime Kuma**, **Proxmox Backup Server**, and **osTicket** + each get their own top-level page (backed by the matching integration) + instead of living inside a shared Integrations browsing view: - **Tailscale** — device list with online/authorized status, and authorize/deauthorize/remove actions; a live device-count widget. - **Proxmox** — VM/LXC status across every node in the cluster, with @@ -124,11 +126,18 @@ All modules from the original plan are built: still passes; a daily check alerts on any snapshot that's failed verification or a datastore that couldn't be read. Read-only, no actions (nothing here can prune, delete, or trigger a re-verify). + - **osTicket** — every currently open ticket, with its status, priority, + department, assigned staff member or team, requester, and two flags + worth a glance on their own: **overdue** and **awaiting our reply**. + osTicket's own REST API only supports *creating* tickets, not listing + them, so this reads osTicket's MySQL/MariaDB database directly with a + read-only user — the only integration here that isn't a REST API. + Read-only, no actions. The Integrations page itself is now just a list of configured integrations (name/type/status, visible to every role) with an admin-only "Add integration" button and edit/enable/disable/delete - actions per row — the eight dedicated pages above are where you + actions per row — the nine dedicated pages above are where you actually use each one. - Every table in the app is click-to-sort on any column (numbers, booleans, and dates/text sort correctly regardless of how the column formats them) @@ -164,8 +173,8 @@ assumed HTTPS-only (the NAS is reached over plain HTTP), and the Tailscale adapter read `online`/`isExitNode` fields that don't actually exist in the real API response (fixed to derive them from `connectedToControl` and `enabledRoutes`). See the git log for the full verification notes per -integration. (Uptime Kuma and Proxmox Backup Server, added later, are not -part of that "six" — see their own git log entries, and +integration. (Uptime Kuma, Proxmox Backup Server, and osTicket, added +later, are not part of that "six" — see their own git log entries, and [INTEGRATIONS.md](INTEGRATIONS.md), for what was and wasn't verified against a real instance.) diff --git a/ROLES.md b/ROLES.md index c13d062..bc8c8b3 100644 --- a/ROLES.md +++ b/ROLES.md @@ -38,6 +38,7 @@ page's own top-level link. | **Operations** | | | | | | Maintenance | `/maintenance` | ✅ | ✅ | ✅ | | Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ | +| osTicket | `/osticket` | ✅ | ✅ | ✅ | | Generator | `/generator` | ✅ | ✅ | ✅ | | **Administration** | | | | | | Integrations | `/integrations` | ✅ | ✅ | ✅ | diff --git a/package-lock.json b/package-lock.json index 0a2c87e..527ba2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2156,6 +2156,15 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/bagpipe": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", @@ -2973,6 +2982,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3131,6 +3149,12 @@ "node": ">= 0.10" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -3234,6 +3258,12 @@ "@libsql/win32-x64-msvc": "0.4.7" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -3262,6 +3292,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -3352,6 +3397,55 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz", + "integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -4067,6 +4161,21 @@ "source-map": "^0.6.0" } }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4893,6 +5002,7 @@ "drizzle-orm": "^0.45.2", "express": "^4.21.2", "express-session": "^1.18.1", + "mysql2": "^3.24.5", "node-schedule": "^2.1.1", "nodemailer": "^6.9.14", "openid-client": "^6.1.7", diff --git a/server/package.json b/server/package.json index 045f59e..8ef9270 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "drizzle-orm": "^0.45.2", "express": "^4.21.2", "express-session": "^1.18.1", + "mysql2": "^3.24.5", "node-schedule": "^2.1.1", "nodemailer": "^6.9.14", "openid-client": "^6.1.7", diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 78929ff..61aab7e 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -289,6 +289,7 @@ export const integrationTypes = [ "uptimekuma", "phpipam", "pbs", + "osticket", ] as const; export type IntegrationType = (typeof integrationTypes)[number]; diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index f1c9580..69695ea 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -62,6 +62,20 @@ export const INTEGRATION_FIELDS: Partial; + listOpenTickets(): Promise; +} + +function toIso(value: unknown): string | null { + if (value instanceof Date) return value.toISOString(); + return null; +} + +// The prefix is spliced directly into table names below (MySQL has no way to parameterize an +// identifier), so it's restricted to what a real identifier can contain rather than trusted as-is. +const SAFE_PREFIX = /^[A-Za-z0-9_]*$/; + +export function createOsTicketAdapter(config: OsTicketConfig): OsTicketAdapter { + const prefix = config.tablePrefix?.trim() || "ost_"; + if (!SAFE_PREFIX.test(prefix)) { + throw new Error("Table prefix may only contain letters, numbers, and underscores"); + } + const port = Number(config.port) || 3306; + + async function withConnection(fn: (conn: mysql.Connection) => Promise): Promise { + const conn = await mysql.createConnection({ + host: config.host, + port, + database: config.database, + user: config.username, + password: config.password, + connectTimeout: 10_000, + }); + try { + return await fn(conn); + } finally { + await conn.end().catch(() => {}); + } + } + + async function listOpenTickets(): Promise { + const sql = ` + SELECT + t.ticket_id AS ticketId, + t.number AS number, + cdata.subject AS subject, + ts.name AS statusName, + tp.priority AS priorityName, + tp.priority_color AS priorityColor, + d.name AS departmentName, + CASE WHEN s.staff_id IS NOT NULL THEN TRIM(CONCAT(s.firstname, ' ', s.lastname)) ELSE NULL END AS staffName, + tm.name AS teamName, + u.name AS requesterName, + ue.address AS requesterEmail, + t.source AS source, + t.isoverdue AS isOverdue, + t.isanswered AS isAnswered, + t.created AS createdAt, + t.lastupdate AS lastActivityAt, + t.duedate AS dueAt + FROM ${prefix}ticket t + JOIN ${prefix}ticket_status ts ON ts.id = t.status_id + LEFT JOIN ${prefix}ticket__cdata cdata ON cdata.ticket_id = t.ticket_id + LEFT JOIN ${prefix}ticket_priority tp ON tp.priority_id = cdata.priority + LEFT JOIN ${prefix}department d ON d.id = t.dept_id + LEFT JOIN ${prefix}staff s ON s.staff_id = t.staff_id + LEFT JOIN ${prefix}team tm ON tm.team_id = t.team_id + LEFT JOIN ${prefix}user u ON u.id = t.user_id + LEFT JOIN ${prefix}user_email ue ON ue.id = t.user_email_id + WHERE ts.state = 'open' + ORDER BY t.isoverdue DESC, t.created ASC + `; + + return withConnection(async (conn) => { + const [rows] = await conn.query(sql); + return rows.map((row) => ({ + ticketId: Number(row.ticketId), + number: String(row.number), + subject: row.subject ?? null, + statusName: String(row.statusName), + priorityName: row.priorityName ?? null, + priorityColor: row.priorityColor ?? null, + departmentName: row.departmentName ?? null, + staffName: row.staffName || null, + teamName: row.teamName ?? null, + requesterName: row.requesterName ?? null, + requesterEmail: row.requesterEmail ?? null, + source: row.source ?? null, + isOverdue: Boolean(row.isOverdue), + isAnswered: Boolean(row.isAnswered), + createdAt: toIso(row.createdAt) ?? new Date(0).toISOString(), + lastActivityAt: toIso(row.lastActivityAt), + dueAt: toIso(row.dueAt), + })); + }); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await withConnection((conn) => conn.query("SELECT 1")); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return withDiagLogging("osticket", { ping, listOpenTickets }); +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts index f76b686..219711f 100644 --- a/server/src/integrations/registry.ts +++ b/server/src/integrations/registry.ts @@ -9,6 +9,7 @@ import { createSynologyAdapter } from "./synology/adapter.js"; import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js"; import { createPhpIpamAdapter } from "./phpipam/adapter.js"; import { createPbsAdapter } from "./pbs/adapter.js"; +import { createOsTicketAdapter } from "./osticket/adapter.js"; export interface PingableAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; @@ -41,6 +42,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createPhpIpamAdapter(config as any); case "pbs": return createPbsAdapter(config as any); + case "osticket": + return createOsTicketAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 42821bb..b152d77 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -22,6 +22,7 @@ import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integratio import { createSynologyAdapter } from "../integrations/synology/adapter.js"; import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js"; import { createPbsAdapter } from "../integrations/pbs/adapter.js"; +import { createOsTicketAdapter } from "../integrations/osticket/adapter.js"; import { attachMatchedServers, summarizeMonitors, toMatchableServers } from "../services/uptimeKumaMatch.js"; import { asyncHandler } from "../utils/asyncHandler.js"; @@ -858,3 +859,44 @@ integrationsRouter.get("/:id/pbs/status", asyncHandler(async (req, res) => { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } })); + +// ─── osTicket ──────────────────────────────────────────────────────────────── +// Read-only, and the only integration that reads a database directly rather +// than an HTTP API — see server/src/integrations/osticket/adapter.ts for why. + +async function requireOsTicketAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "osticket") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createOsTicketAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/osticket/tickets", asyncHandler(async (req, res) => { + const found = await requireOsTicketAdapter(req, res); + if (!found) return; + + try { + const tickets = await found.adapter.listOpenTickets(); + res.json({ + tickets, + summary: { + total: tickets.length, + overdue: tickets.filter((t) => t.isOverdue).length, + awaitingReply: tickets.filter((t) => !t.isAnswered).length, + }, + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); diff --git a/web/src/App.tsx b/web/src/App.tsx index e9f14fa..2c8d168 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,6 +21,7 @@ import Proxmox from "./pages/Proxmox"; import Synology from "./pages/Synology"; import UptimeKuma from "./pages/UptimeKuma"; import PbsBackup from "./pages/PbsBackup"; +import OsTicket from "./pages/OsTicket"; import Generator from "./pages/Generator"; import Maintenance from "./pages/Maintenance"; import Domains from "./pages/Domains"; @@ -107,6 +108,7 @@ export default function App() { } /> } /> } /> + } /> request(`/api/integrations/${integrationId}/pbs/status`), }, + osticket: { + tickets: (integrationId: number) => + request(`/api/integrations/${integrationId}/osticket/tickets`), + }, semaphore: { templates: (integrationId: number) => request(`/api/integrations/${integrationId}/semaphore/templates`), diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 145d821..cd77b5d 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -20,6 +20,7 @@ const INTEGRATION_TYPE_LABELS: Record = { uptimekuma: "Uptime Kuma", phpipam: "phpIPAM", pbs: "Proxmox Backup Server", + osticket: "osTicket", }; const DNS_PROVIDER_LABELS: Record = { diff --git a/web/src/components/IntegrationEditForm.tsx b/web/src/components/IntegrationEditForm.tsx index 191125f..1ded624 100644 --- a/web/src/components/IntegrationEditForm.tsx +++ b/web/src/components/IntegrationEditForm.tsx @@ -11,6 +11,7 @@ const TYPE_LABELS: Record = { uptimekuma: "Uptime Kuma", phpipam: "phpIPAM", pbs: "Proxmox Backup Server", + osticket: "osTicket", }; export default function IntegrationEditForm({ diff --git a/web/src/components/IntegrationForm.tsx b/web/src/components/IntegrationForm.tsx index b09f29c..28b01dd 100644 --- a/web/src/components/IntegrationForm.tsx +++ b/web/src/components/IntegrationForm.tsx @@ -11,6 +11,7 @@ const TYPE_LABELS: Record = { uptimekuma: "Uptime Kuma", phpipam: "phpIPAM", pbs: "Proxmox Backup Server", + osticket: "osTicket", }; export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) { diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 5baa0ff..d7ba322 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -29,6 +29,7 @@ import { IconRefresh, IconActivityHeartbeat, IconShieldCheck, + IconTicket, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; @@ -99,6 +100,7 @@ const NAV: NavEntry[] = [ items: [ { to: "/maintenance", label: "Maintenance", icon: }, { to: "/uptime-kuma", label: "Uptime Kuma", icon: }, + { to: "/osticket", label: "osTicket", icon: }, { to: "/generator", label: "Generator", icon: }, ], }, diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 4d862d7..e7ce718 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { type DomainList, type IntegrationSummary, type IntegrationType, + type OsTicketTicketsResponse, type PbsDatastoresResponse, type SecretRecord, type ServerSummary, @@ -204,6 +205,7 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "dockhand", label: "Dockhand / Docker" }, { type: "uptimekuma", label: "Uptime Kuma" }, { type: "pbs", label: "Proxmox Backup Server" }, + { type: "osticket", label: "osTicket" }, ]; interface UptimeKumaSummary { @@ -214,6 +216,12 @@ interface UptimeKumaSummary { statusBreakdown: { label: string; count: number }[]; } +interface OsTicketSummary { + total: number; + overdue: number; + awaitingReply: number; +} + interface PbsSummary { datastoreCount: number; failedSnapshotCount: number; @@ -286,6 +294,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [synologySummary, setSynologySummary] = useState(null); const [uptimeKumaSummary, setUptimeKumaSummary] = useState(null); const [pbsSummary, setPbsSummary] = useState(null); + const [osTicketSummary, setOsTicketSummary] = useState(null); const [dnsStats, setDnsStats] = useState> | null>(null); const [dnsError, setDnsError] = useState(null); @@ -487,6 +496,24 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setPbsSummary(null)); }, [integrations]); + useEffect(() => { + const osticket = integrations?.find((i) => i.type === "osticket" && i.enabled); + if (!osticket) { + setOsTicketSummary(null); + return; + } + api.integrations.osticket + .tickets(osticket.id) + .then((res: OsTicketTicketsResponse) => + setOsTicketSummary({ + total: res.summary.total, + overdue: res.summary.overdue, + awaitingReply: res.summary.awaitingReply, + }), + ) + .catch(() => setOsTicketSummary(null)); + }, [integrations]); + const domains = domainList?.domains ?? []; const expiredDomains = domains.filter((d) => d.status === "expired"); const expiringDomains = domains.filter((d) => d.status === "expiring"); @@ -791,7 +818,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{WIDGETS.map(({ type, label }) => { @@ -804,6 +831,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLiveSynology = type === "synology" && integration && synologySummary; const isLiveUptimeKuma = type === "uptimekuma" && integration && uptimeKumaSummary; const isLivePbs = type === "pbs" && integration && pbsSummary; + const isLiveOsTicket = type === "osticket" && integration && osTicketSummary; const isLive = isLiveTailscale || isLiveGitea || @@ -812,7 +840,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) { isLiveProxmox || isLiveSynology || isLiveUptimeKuma || - isLivePbs; + isLivePbs || + isLiveOsTicket; return (
+ ) : isLiveOsTicket ? ( +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ 0 ? "#f59e0b" : undefined} + /> +
+
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 590ae4b..9b2f0fd 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -17,6 +17,7 @@ const TYPE_LABELS: Record = { uptimekuma: "Uptime Kuma", phpipam: "phpIPAM", pbs: "Proxmox Backup Server", + osticket: "osTicket", }; function typeBadgeStyle(colors: Record, type: IntegrationType): CSSProperties { diff --git a/web/src/pages/OsTicket.tsx b/web/src/pages/OsTicket.tsx new file mode 100644 index 0000000..7ba065a --- /dev/null +++ b/web/src/pages/OsTicket.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type IntegrationSummary, type OsTicketTicket, type OsTicketTicketsResponse } from "../api/client"; +import { useSortable } from "../hooks/useSortable"; +import SortableTh from "../components/SortableTh"; +import { downloadCsv } from "../utils/csv"; +import { formatDateTime } from "../utils/date"; +import { formatAgo } from "../utils/duration"; + +function priorityBadge(t: OsTicketTicket) { + if (!t.priorityName) return —; + const color = t.priorityColor || "#6c757d"; + return ( + + {t.priorityName} + + ); +} + +export default function OsTicket({ user: _user }: { user: CurrentUser }) { + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const osticketIntegrations = res.integrations.filter((i) => i.type === "osticket"); + setIntegrations(osticketIntegrations); + if (!selectedId && osticketIntegrations.length > 0) setSelectedId(osticketIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadTickets(id: number) { + setLoading(true); + setError(null); + api.integrations.osticket + .tickets(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadTickets(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const { sorted: sortedTickets, sortKey, sortDir, requestSort } = useSortable(data?.tickets); + + function exportCsv() { + if (!sortedTickets) return; + downloadCsv( + "osticket-open-tickets.csv", + ["Number", "Subject", "Status", "Priority", "Department", "Assigned to", "Requester", "Overdue", "Awaiting reply", "Created", "Last activity", "Due"], + sortedTickets.map((t) => [ + t.number, + t.subject ?? "", + t.statusName, + t.priorityName ?? "", + t.departmentName ?? "", + t.staffName ?? t.teamName ?? "Unassigned", + t.requesterName ?? t.requesterEmail ?? "", + t.isOverdue ? "yes" : "no", + t.isAnswered ? "no" : "yes", + formatDateTime(new Date(t.createdAt)), + t.lastActivityAt ? formatDateTime(new Date(t.lastActivityAt)) : "", + t.dueAt ? formatDateTime(new Date(t.dueAt)) : "", + ]), + ); + } + + return ( + <> +

osTicket

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No osTicket integration configured yet. Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + tickets. +
+
+ ) : ( +
+
+
+
+
+
+
Open tickets
+
{data?.summary.total ?? "—"}
+
+
+
+
+
+
+
Overdue
+
0 ? "text-red" : ""}`}> + {data?.summary.overdue ?? "—"} +
+
+
+
+
+
+
+
Awaiting our reply
+
0 ? "text-yellow" : ""}`}> + {data?.summary.awaitingReply ?? "—"} +
+
+
+
+
+
+
+
+
+

Open tickets

+
+ + +
+
+
+ + + + label="#" sortKeyName="number" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Subject" sortKeyName="subject" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Priority" + sortKeyName="priorityName" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Department" + sortKeyName="departmentName" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + label="Assigned to" sortKeyName="staffName" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Requester" + sortKeyName="requesterName" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + label="Created" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Last activity" + sortKeyName="lastActivityAt" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Flags" + sortKeyName="isOverdue" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + + + {sortedTickets?.map((t) => ( + + + + + + + + + + + + ))} + {sortedTickets?.length === 0 && ( + + + + )} + +
{t.number} + {t.subject || (no subject)} +
{t.statusName}
+
{priorityBadge(t)}{t.departmentName ?? "—"}{t.staffName ?? t.teamName ?? "Unassigned"}{t.requesterName ?? t.requesterEmail ?? "—"} + {formatAgo(t.createdAt)} + + {t.lastActivityAt ? ( + {formatAgo(t.lastActivityAt)} + ) : ( + "—" + )} + + {t.isOverdue && Overdue} + {!t.isAnswered && Awaiting reply} +
+ No open tickets. +
+
+
+
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/settings/BadgeSettings.tsx b/web/src/pages/settings/BadgeSettings.tsx index c82b725..27e303f 100644 --- a/web/src/pages/settings/BadgeSettings.tsx +++ b/web/src/pages/settings/BadgeSettings.tsx @@ -20,6 +20,7 @@ const INTEGRATION_NAMES: Record = { uptimekuma: "Uptime Kuma", phpipam: "phpIPAM", pbs: "Proxmox Backup Server", + osticket: "osTicket", }; function ColorList({