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({