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:
1 parent
69e9325927
commit
de5c39dddf
20 files changed
+754
-12
No files matched your search
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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} /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
pbs: "Proxmox Backup Server",
|
||||
osticket: "osTicket",
|
||||
};
|
||||
|
||||
function ColorList({
|
||||
|
||||
Reference in new issue
Block a user