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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 23:09:56 +02:00
co-authored by Claude Sonnet 5
parent 69e9325927
commit de5c39dddf
20 changed files with 754 additions and 12 deletions
+44
View File
@@ -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
+18 -9
View File
@@ -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.)
+1
View File
@@ -38,6 +38,7 @@ page's own top-level link.
| **Operations** | | | | |
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
| osTicket | `/osticket` | ✅ | ✅ | ✅ |
| Generator | `/generator` | ✅ | ✅ | ✅ |
| **Administration** | | | | |
| Integrations | `/integrations` | ✅ | ✅ | ✅ |
+110
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -289,6 +289,7 @@ export const integrationTypes = [
"uptimekuma",
"phpipam",
"pbs",
"osticket",
] as const;
export type IntegrationType = (typeof integrationTypes)[number];
+14
View File
@@ -62,6 +62,20 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
osticket: [
{ key: "host", label: "Database host", secret: false, placeholder: "osticket-db.example.lan" },
{ key: "port", label: "Database port", secret: false, optional: true, placeholder: "3306" },
{ key: "database", label: "Database name", secret: false, placeholder: "osticket" },
{ key: "username", label: "Database username", secret: false, placeholder: "read-only user" },
{ key: "password", label: "Database password", secret: true, type: "password" },
{
key: "tablePrefix",
label: "Table prefix",
secret: false,
optional: true,
placeholder: "ost_ (osTicket's default, unless changed at install)",
},
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+162
View File
@@ -0,0 +1,162 @@
/**
* osTicket adapter — reads directly from osTicket's own MySQL/MariaDB database.
* Requires config: host, database, username, password; optional: port (default 3306), tablePrefix
* (default "ost_", configurable at osTicket install time), insecure (skip TLS cert verification,
* only meaningful if the DB itself is reached over TLS).
*
* osTicket's own REST API only supports *creating* tickets (POST /api/tickets.json) — there is no
* official endpoint to list or read existing ones (confirmed against osTicket's own developer
* docs). Listing tickets therefore means reading the database directly with a read-only user, the
* same way osTicket's own admin panel does internally. This is the only integration in this app
* that isn't a REST API for that reason.
*
* Ticket status names are fully customizable per install ("Open" might be renamed), but every
* status maps to a fixed `state` column of either "open" or "closed" — filtering on `state` stays
* correct regardless of what the admin renamed things to.
*
* Subject and priority aren't columns on the ticket table itself — osTicket normalizes them into
* its dynamic custom-fields system. `ost_ticket__cdata` is a denormalized cache of exactly those
* two fields that osTicket's own admin panel reads from for ticket lists (faster than joining the
* generic form-fields tables), but by osTicket's own design it's a regenerated cache tied to the
* "Ticket Details" form — GitHub issues on the osTicket repo document it occasionally going stale
* or briefly missing after a form change. It's LEFT JOINed here (not required) so a ticket with no
* matching cdata row still shows up, just with an empty subject/priority rather than being dropped.
*/
import mysql from "mysql2/promise";
import { withDiagLogging } from "../../services/diagLog.js";
export interface OsTicketConfig {
host: string;
port?: string;
database: string;
username: string;
password: string;
tablePrefix?: string;
}
export interface OsTicketTicket {
ticketId: number;
number: string;
subject: string | null;
statusName: string;
priorityName: string | null;
priorityColor: string | null;
departmentName: string | null;
staffName: string | null;
teamName: string | null;
requesterName: string | null;
requesterEmail: string | null;
source: string | null;
isOverdue: boolean;
isAnswered: boolean;
createdAt: string;
lastActivityAt: string | null;
dueAt: string | null;
}
export interface OsTicketAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listOpenTickets(): Promise<OsTicketTicket[]>;
}
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<T>(fn: (conn: mysql.Connection) => Promise<T>): Promise<T> {
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<OsTicketTicket[]> {
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<mysql.RowDataPacket[]>(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 });
}
+3
View File
@@ -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`);
}
+42
View File
@@ -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) });
}
}));
+2
View File
@@ -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() {
<Route path="/synology" element={<Synology user={user} />} />
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
<Route path="/pbs" element={<PbsBackup user={user} />} />
<Route path="/osticket" element={<OsTicket user={user} />} />
<Route
path="/users"
element={
+30 -1
View File
@@ -429,7 +429,7 @@ export interface ManualTaskInput {
enabled?: boolean;
}
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam" | "pbs";
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam" | "pbs" | "osticket";
export interface IntegrationField {
key: string;
@@ -671,6 +671,31 @@ export interface PbsNodeStatus {
uptime: number | null;
}
export interface OsTicketTicket {
ticketId: number;
number: string;
subject: string | null;
statusName: string;
priorityName: string | null;
priorityColor: string | null;
departmentName: string | null;
staffName: string | null;
teamName: string | null;
requesterName: string | null;
requesterEmail: string | null;
source: string | null;
isOverdue: boolean;
isAnswered: boolean;
createdAt: string;
lastActivityAt: string | null;
dueAt: string | null;
}
export interface OsTicketTicketsResponse {
tickets: OsTicketTicket[];
summary: { total: number; overdue: number; awaitingReply: number };
}
export type SemaphoreTaskStatus =
| "waiting"
| "starting"
@@ -1157,6 +1182,10 @@ export const api = {
status: (integrationId: number) =>
request<PbsNodeStatus>(`/api/integrations/${integrationId}/pbs/status`),
},
osticket: {
tickets: (integrationId: number) =>
request<OsTicketTicketsResponse>(`/api/integrations/${integrationId}/osticket/tickets`),
},
semaphore: {
templates: (integrationId: number) =>
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
+1
View File
@@ -20,6 +20,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
};
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
@@ -11,6 +11,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
};
export default function IntegrationEditForm({
+1
View File
@@ -11,6 +11,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
};
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
+2
View File
@@ -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: <IconTool size={20} /> },
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
],
},
+51 -2
View File
@@ -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<SynologySummary | null>(null);
const [uptimeKumaSummary, setUptimeKumaSummary] = useState<UptimeKumaSummary | null>(null);
const [pbsSummary, setPbsSummary] = useState<PbsSummary | null>(null);
const [osTicketSummary, setOsTicketSummary] = useState<OsTicketSummary | null>(null);
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
const [dnsError, setDnsError] = useState<string | null>(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 }) {
<SectionHeader
title="Integrations"
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, Uptime Kuma, and Proxmox Backup Server will appear here as each integration is connected."
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, Uptime Kuma, Proxmox Backup Server, and osTicket will appear here as each integration is connected."
/>
<div className="row row-cards">
{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 (
<WidgetCard
@@ -1008,6 +1037,26 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
/>
</div>
</div>
) : isLiveOsTicket ? (
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Open" value={osTicketSummary!.total} />
</div>
<div className="col-4">
<MiniStat
label="Overdue"
value={osTicketSummary!.overdue}
accent={osTicketSummary!.overdue > 0 ? "#ef4444" : undefined}
/>
</div>
<div className="col-4">
<MiniStat
label="Awaiting reply"
value={osTicketSummary!.awaitingReply}
accent={osTicketSummary!.awaitingReply > 0 ? "#f59e0b" : undefined}
/>
</div>
</div>
) : (
<div className="text-secondary mt-2">
{integration ? (
+1
View File
@@ -17,6 +17,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
};
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
+268
View File
@@ -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 <span className="text-secondary">—</span>;
const color = t.priorityColor || "#6c757d";
return (
<span className="badge" style={{ backgroundColor: `${color}22`, color, border: `1px solid ${color}55` }}>
{t.priorityName}
</span>
);
}
export default function OsTicket({ user: _user }: { user: CurrentUser }) {
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<OsTicketTicketsResponse | null>(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 (
<>
<h2 className="page-title mb-3">osTicket</h2>
{error && <div className="alert alert-danger">{error}</div>}
{integrations?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No osTicket integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
</div>
</div>
) : (
<>
{integrations && integrations.length > 1 && (
<div className="mb-3" style={{ maxWidth: 320 }}>
<select
className="form-select"
value={selectedId ?? ""}
onChange={(e) => setSelectedId(Number(e.target.value))}
>
{integrations.map((i) => (
<option key={i.id} value={i.id} disabled={!i.enabled}>
{i.name}
{!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
)}
{selected && !selected.enabled ? (
<div className="card">
<div className="card-body text-secondary">
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
tickets.
</div>
</div>
) : (
<div className="row g-3">
<div className="col-12">
<div className="row g-3 mb-1">
<div className="col-6 col-md-4">
<div className="card card-sm">
<div className="card-body">
<div className="text-secondary">Open tickets</div>
<div className="h2 mb-0">{data?.summary.total ?? "—"}</div>
</div>
</div>
</div>
<div className="col-6 col-md-4">
<div className="card card-sm">
<div className="card-body">
<div className="text-secondary">Overdue</div>
<div className={`h2 mb-0 ${data && data.summary.overdue > 0 ? "text-red" : ""}`}>
{data?.summary.overdue ?? "—"}
</div>
</div>
</div>
</div>
<div className="col-6 col-md-4">
<div className="card card-sm">
<div className="card-body">
<div className="text-secondary">Awaiting our reply</div>
<div className={`h2 mb-0 ${data && data.summary.awaitingReply > 0 ? "text-yellow" : ""}`}>
{data?.summary.awaitingReply ?? "—"}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Open tickets</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={exportCsv}
disabled={!sortedTickets || sortedTickets.length === 0}
>
Export CSV
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadTickets(selected.id)}
disabled={loading}
>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<OsTicketTicket> label="#" sortKeyName="number" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<OsTicketTicket> label="Subject" sortKeyName="subject" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<OsTicketTicket>
label="Priority"
sortKeyName="priorityName"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<OsTicketTicket>
label="Department"
sortKeyName="departmentName"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<OsTicketTicket> label="Assigned to" sortKeyName="staffName" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<OsTicketTicket>
label="Requester"
sortKeyName="requesterName"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<OsTicketTicket> label="Created" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<OsTicketTicket>
label="Last activity"
sortKeyName="lastActivityAt"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<OsTicketTicket>
label="Flags"
sortKeyName="isOverdue"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
</tr>
</thead>
<tbody>
{sortedTickets?.map((t) => (
<tr key={t.ticketId}>
<td>{t.number}</td>
<td>
{t.subject || <span className="text-secondary">(no subject)</span>}
<div className="text-secondary small">{t.statusName}</div>
</td>
<td>{priorityBadge(t)}</td>
<td className="text-secondary">{t.departmentName ?? "—"}</td>
<td className="text-secondary">{t.staffName ?? t.teamName ?? "Unassigned"}</td>
<td className="text-secondary">{t.requesterName ?? t.requesterEmail ?? "—"}</td>
<td className="text-secondary" title={formatDateTime(new Date(t.createdAt))}>
{formatAgo(t.createdAt)}
</td>
<td className="text-secondary">
{t.lastActivityAt ? (
<span title={formatDateTime(new Date(t.lastActivityAt))}>{formatAgo(t.lastActivityAt)}</span>
) : (
"—"
)}
</td>
<td>
{t.isOverdue && <span className="badge bg-red-lt text-red me-1">Overdue</span>}
{!t.isAnswered && <span className="badge bg-yellow-lt text-yellow">Awaiting reply</span>}
</td>
</tr>
))}
{sortedTickets?.length === 0 && (
<tr>
<td colSpan={9} className="text-secondary text-center">
No open tickets.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</>
)}
</>
);
}
+1
View File
@@ -20,6 +20,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
};
function ColorList({