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:
bobbanandClaude Sonnet 5 committed 2026-09-29 23:09:56 +02:00
1 parent 69e9325927
commit de5c39dddf
20 files changed
+754 -12

No files matched your search

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