Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f684626c | ||
|
|
051ef6c915 | ||
|
|
13a0612f23 | ||
|
|
4885848375 |
@@ -9,3 +9,4 @@ uploads/*
|
||||
!uploads/.htaccess
|
||||
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
|
||||
vendor/
|
||||
scripts/demo/__pycache__/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Jared Vititoe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -8,6 +8,62 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||
|
||||

|
||||
|
||||
## Why this exists
|
||||
|
||||
Running a six-node Proxmox/Ceph cluster generates a steady stream of small problems: a drive with SMART warnings, a link that flaps, a certificate about to expire. Off-the-shelf helpdesks are heavyweight for that, and a plain todo list loses the history. Tinker Tickets is a small, opinionated ticketing system built for exactly this: tickets are created **automatically by monitoring** (and by hand), deduplicated so a repeating alert updates one ticket instead of flooding the queue, and every change is audited.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
hw["hwmonDaemon<br/>SMART, Ceph, temps"] -->|Bearer API| tt
|
||||
gd["GANDALF<br/>network regressions"] -->|Bearer API| tt
|
||||
mcp["MCP server<br/>Claude Code / agents (OAuth)"] --> tt
|
||||
ui["Web UI<br/>people"] --> tt
|
||||
tt{{"Tinker Tickets<br/>PHP + MariaDB"}} -->|webhook| mx["Matrix notifications"]
|
||||
tt --> audit[("Audit log<br/>timeline per ticket")]
|
||||
```
|
||||
|
||||
## Gallery
|
||||
|
||||
**Dashboard**: live stat cards, priority and status breakdowns, team workload and a filterable queue.
|
||||
|
||||

|
||||
|
||||
**Kanban view** with per-column counts and priority-coloured cards:
|
||||
|
||||

|
||||
|
||||
**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace:
|
||||
|
||||

|
||||
|
||||
| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments):
|
||||
|
||||

|
||||
|
||||
**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets:
|
||||
|
||||

|
||||
|
||||
**Admin tools:**
|
||||
|
||||
| Workflow designer | API keys (scoped, revocable) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Audit log** | **Recurring tickets** |
|
||||
|  |  |
|
||||
|
||||
**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle):
|
||||
|
||||

|
||||
|
||||
<sub>Screenshots are generated with Playwright against a throwaway local instance seeded with fictional demo data (`scripts/demo/`).</sub>
|
||||
|
||||
## Styling & Layout
|
||||
|
||||
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||
|
||||
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -141,9 +141,12 @@ foreach ($pending as $file) {
|
||||
// DDL.
|
||||
try {
|
||||
// Split by semicolon but respect statements properly
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple.
|
||||
// Full-line "--" comments are stripped first so a ";" inside a comment
|
||||
// can't cut a statement in half.
|
||||
$sqlNoComments = preg_replace('/^\s*--.*$/m', '', $sql);
|
||||
$statements = array_values(array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
array_map('trim', explode(';', $sqlNoComments)),
|
||||
function($stmt) {
|
||||
// Remove comments and check if there's actual SQL
|
||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Demo environment (for README screenshots)
|
||||
|
||||
Everything here uses **fictional data only**. It spins up a throwaway MariaDB on a private socket and PHP's
|
||||
built-in server, so nothing touches a real database.
|
||||
|
||||
```bash
|
||||
D=$(mktemp -d); cp -r . $D/tinker && cp scripts/demo/router.php scripts/demo/seed.py scripts/demo/capture.py $D/
|
||||
cd $D
|
||||
printf 'DB_HOST=localhost\nDB_USER=root\nDB_PASS=\nDB_NAME=ticketing_system\n' > tinker/.env
|
||||
mariadb-install-db --no-defaults --datadir=$D/db/data --auth-root-authentication-method=normal --skip-test-db
|
||||
mariadbd --no-defaults --datadir=$D/db/data --socket=$D/db/sock --skip-networking --user=root &
|
||||
mariadb --no-defaults -S $D/db/sock -e 'CREATE DATABASE ticketing_system CHARACTER SET utf8mb4'
|
||||
(cd tinker && php -d mysqli.default_socket=$D/db/sock migrations/migrate.php)
|
||||
python3 seed.py && mariadb --no-defaults -S $D/db/sock ticketing_system < seed.sql
|
||||
php -d mysqli.default_socket=$D/db/sock -S 127.0.0.1:8300 -t tinker router.php &
|
||||
python3 capture.py # screenshots + GIF -> docs/img/
|
||||
```
|
||||
|
||||
The app trusts Authelia `Remote-User` headers, so `capture.py` sends them directly. Requires `playwright` and `pillow`.
|
||||
@@ -0,0 +1,174 @@
|
||||
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
|
||||
import asyncio
|
||||
import io
|
||||
import sys
|
||||
from PIL import Image
|
||||
from playwright.async_api import async_playwright
|
||||
import pathlib
|
||||
|
||||
OUT = str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "img")
|
||||
B = "http://127.0.0.1:8300"
|
||||
H = {
|
||||
"Remote-User": "alex.rivera",
|
||||
"Remote-Name": "Alex Rivera",
|
||||
"Remote-Email": "alex@example.com",
|
||||
"Remote-Groups": "admin,employee",
|
||||
}
|
||||
P1 = "152847156"
|
||||
CEPH = "524938499"
|
||||
JS_TOP = (
|
||||
"document.activeElement&&document.activeElement.blur();"
|
||||
"document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)"
|
||||
)
|
||||
|
||||
|
||||
async def ctxmk(p, theme="dark", w=1440, h=900):
|
||||
b = await p.chromium.launch()
|
||||
c = await b.new_context(viewport={"width": w, "height": h}, extra_http_headers=H)
|
||||
await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}')}}catch(e){{}}")
|
||||
|
||||
async def avatar(route):
|
||||
import re
|
||||
|
||||
uid = int((re.search(r"user_id=(\d+)", route.request.url) or [0, 0])[1] or 0)
|
||||
ini = {1: "AR", 2: "SC", 3: "RB", 4: "JK", 5: "HW"}.get(uid, "??")
|
||||
col = {1: "#ff6b00", 2: "#00d4ff", 3: "#b44aff", 4: "#00ff88", 5: "#ffc800"}.get(uid, "#888")
|
||||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#050a10"/><text x="32" y="40" font-family="monospace" font-size="24" font-weight="700" fill="{col}" text-anchor="middle">{ini}</text></svg>'
|
||||
await route.fulfill(status=200, content_type="image/svg+xml", body=svg)
|
||||
|
||||
await c.route("**/api/user_avatar.php*", avatar)
|
||||
return b, c
|
||||
|
||||
|
||||
async def go(pg, path, wait=3500):
|
||||
await pg.goto(B + path)
|
||||
await pg.wait_for_timeout(wait)
|
||||
await pg.evaluate(JS_TOP)
|
||||
await pg.wait_for_timeout(300)
|
||||
|
||||
|
||||
async def tab(pg, name):
|
||||
t = pg.get_by_role("tab", name=name).first
|
||||
if await t.count() == 0:
|
||||
t = pg.get_by_text(name, exact=False).first
|
||||
await t.click(timeout=4000)
|
||||
await pg.wait_for_timeout(900)
|
||||
|
||||
|
||||
async def shots():
|
||||
async with async_playwright() as p:
|
||||
b, c = await ctxmk(p)
|
||||
pg = await c.new_page()
|
||||
await go(pg, "/", 5000)
|
||||
await pg.screenshot(path=f"{OUT}/dashboard.png")
|
||||
# table crop
|
||||
await pg.set_viewport_size({"width": 1840, "height": 900})
|
||||
await pg.wait_for_timeout(600)
|
||||
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
|
||||
await pg.wait_for_timeout(500)
|
||||
await pg.screenshot(path=f"{OUT}/ticket-queue.png")
|
||||
await pg.set_viewport_size({"width": 1440, "height": 900})
|
||||
await pg.wait_for_timeout(600)
|
||||
try:
|
||||
await tab(pg, "Kanban")
|
||||
await pg.screenshot(path=f"{OUT}/kanban.png")
|
||||
print("kanban ok")
|
||||
except Exception as e:
|
||||
print("kanban fail", str(e)[:80])
|
||||
await go(pg, "/ticket/" + P1)
|
||||
await pg.screenshot(path=f"{OUT}/ticket-sla.png")
|
||||
await go(pg, "/ticket/" + CEPH)
|
||||
for name, fn in (
|
||||
("Comments", "ticket-comments"),
|
||||
("Dependencies", "ticket-dependencies"),
|
||||
("Activity", "ticket-activity"),
|
||||
):
|
||||
try:
|
||||
await tab(pg, name)
|
||||
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
|
||||
await pg.wait_for_timeout(500)
|
||||
await pg.screenshot(path=f"{OUT}/{fn}.png")
|
||||
print(fn, "ok")
|
||||
except Exception as e:
|
||||
print(fn, "fail", str(e)[:80])
|
||||
await go(pg, "/ticket/create", 3000)
|
||||
await pg.screenshot(path=f"{OUT}/new-ticket.png")
|
||||
await go(pg, "/")
|
||||
await pg.keyboard.press("Control+k")
|
||||
await pg.wait_for_timeout(500)
|
||||
await pg.keyboard.type("ticket", delay=70)
|
||||
await pg.wait_for_timeout(900)
|
||||
await pg.screenshot(path=f"{OUT}/command-palette.png", clip={"x": 320, "y": 60, "width": 800, "height": 460})
|
||||
await pg.keyboard.press("Escape")
|
||||
for path, name in (
|
||||
("/admin/workflow", "admin-workflow"),
|
||||
("/admin/api-keys", "admin-api-keys"),
|
||||
("/admin/audit-log", "admin-audit-log"),
|
||||
("/admin/recurring-tickets", "admin-recurring"),
|
||||
):
|
||||
await go(pg, path, 3500)
|
||||
await pg.screenshot(path=f"{OUT}/{name}.png")
|
||||
print(name, "ok")
|
||||
await b.close()
|
||||
b, c = await ctxmk(p, "light")
|
||||
pg = await c.new_page()
|
||||
await go(pg, "/", 5000)
|
||||
await pg.screenshot(path=f"{OUT}/dashboard-light.png")
|
||||
await b.close()
|
||||
|
||||
|
||||
async def gif():
|
||||
frames = []
|
||||
async with async_playwright() as p:
|
||||
b, c = await ctxmk(p, "dark", 1280, 720)
|
||||
pg = await c.new_page()
|
||||
|
||||
async def snap(n=1, ms=0):
|
||||
if ms:
|
||||
await pg.wait_for_timeout(ms)
|
||||
im = Image.open(io.BytesIO(await pg.screenshot())).convert("RGB").resize((960, 540), Image.LANCZOS)
|
||||
for _ in range(n):
|
||||
frames.append(im)
|
||||
|
||||
await go(pg, "/", 4500)
|
||||
await snap(3)
|
||||
await pg.mouse.wheel(0, 520)
|
||||
await snap(2, 500)
|
||||
try:
|
||||
await tab(pg, "Kanban")
|
||||
await snap(3, 300)
|
||||
except Exception:
|
||||
pass
|
||||
await go(pg, "/ticket/" + P1, 3000)
|
||||
await snap(3)
|
||||
await go(pg, "/ticket/" + CEPH, 3000)
|
||||
await snap(2)
|
||||
for name in ("Comments", "Dependencies", "Activity"):
|
||||
try:
|
||||
await tab(pg, name)
|
||||
await snap(3, 300)
|
||||
except Exception:
|
||||
pass
|
||||
await go(pg, "/", 3000)
|
||||
await pg.keyboard.press("Control+k")
|
||||
await snap(2, 500)
|
||||
for ch in "tick":
|
||||
await pg.keyboard.type(ch)
|
||||
await snap(1, 200)
|
||||
await snap(3, 300)
|
||||
await go(pg, "/admin/workflow", 3000)
|
||||
await snap(3)
|
||||
await b.close()
|
||||
pal = [f.quantize(colors=96, method=Image.MEDIANCUT, dither=Image.NONE) for f in frames]
|
||||
pal[0].save(f"{OUT}/demo.gif", save_all=True, append_images=pal[1:], duration=1100, loop=0, optimize=True)
|
||||
print("gif", len(frames))
|
||||
|
||||
|
||||
async def main():
|
||||
if "gif" not in sys.argv:
|
||||
await shots()
|
||||
if "shots" not in sys.argv:
|
||||
await gif()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Router script for PHP's built-in web server, used only for the local screenshot demo.
|
||||
* Serves real static files directly and sends everything else to the application's index.php.
|
||||
*/
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$file = __DIR__ . '/tinker' . $path;
|
||||
|
||||
if ($path !== '/' && is_file($file) && !str_ends_with($file, '.php')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chdir(__DIR__ . '/tinker');
|
||||
require __DIR__ . '/tinker/index.php';
|
||||
@@ -0,0 +1,344 @@
|
||||
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
random.seed(7)
|
||||
|
||||
D = str(pathlib.Path(__file__).resolve().parent)
|
||||
now = datetime.now().replace(microsecond=0)
|
||||
|
||||
|
||||
def q(s):
|
||||
return "'" + str(s).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
|
||||
def ts(h):
|
||||
return q((now - timedelta(hours=h)).strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
|
||||
sql = []
|
||||
users = [
|
||||
(1, "alex.rivera", "Alex Rivera", "alex@example.com", "admin,employee", 1),
|
||||
(2, "sam.chen", "Sam Chen", "sam@example.com", "employee", 0),
|
||||
(3, "riley.brooks", "Riley Brooks", "riley@example.com", "employee", 0),
|
||||
(4, "jordan.kim", "Jordan Kim", "jordan@example.com", "employee", 0),
|
||||
(5, "hwmon-daemon", "hwmonDaemon", "", "employee", 0),
|
||||
]
|
||||
for u in users:
|
||||
sql.append(
|
||||
f"INSERT INTO users(user_id,username,display_name,email,`groups`,is_admin,last_login) VALUES({u[0]},{q(u[1])},{q(u[2])},{q(u[3])},{q(u[4])},{u[5]},{ts(1)});"
|
||||
)
|
||||
tr = [
|
||||
("Open", "In Progress", 0, 0),
|
||||
("Open", "Pending", 0, 0),
|
||||
("Open", "Closed", 1, 0),
|
||||
("Pending", "Open", 0, 0),
|
||||
("Pending", "In Progress", 0, 0),
|
||||
("Pending", "Closed", 1, 0),
|
||||
("In Progress", "Pending", 0, 0),
|
||||
("In Progress", "Open", 0, 0),
|
||||
("In Progress", "Closed", 1, 0),
|
||||
("Closed", "Open", 0, 1),
|
||||
]
|
||||
for a, b, c, d in tr:
|
||||
sql.append(
|
||||
f"INSERT INTO status_transitions(from_status,to_status,requires_comment,requires_admin) VALUES({q(a)},{q(b)},{c},{d});"
|
||||
)
|
||||
# (title, cat, type, status, prio, assignee, creator, age_h, desc)
|
||||
T = [
|
||||
(
|
||||
"[storage-01][auto][hardware] Drive SN-4F21A8 has SMART issues [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Open",
|
||||
2,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
"SMART reports 12 reallocated sectors and 1 pending sector on /dev/sdc.\n\n```\nReallocated_Sector_Ct 12\nCurrent_Pending_Sector 1\n```\nOSD.14 is backed by this drive. Plan a replacement in the next maintenance window.",
|
||||
),
|
||||
(
|
||||
"[node-02][auto][hardware] CPU temperature sustained above 85C [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Open",
|
||||
2,
|
||||
None,
|
||||
5,
|
||||
5,
|
||||
"15-minute average package temperature 87C (threshold 85C). Fans at 100%. Check airflow and heatsink seating.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][auto][software] Ceph HEALTH_WARN: 3 slow ops on osd.9 [cluster-wide][production][problem]",
|
||||
"Software",
|
||||
"Problem",
|
||||
"In Progress",
|
||||
3,
|
||||
1,
|
||||
5,
|
||||
26,
|
||||
"`ceph health detail` reports slow BlueStore ops on osd.9. PGs remain active+clean. Triage steps in the Ceph runbook.",
|
||||
),
|
||||
(
|
||||
"[node-03][auto][hardware] LXC storage pool above 85% usage [single-node][production][maintenance]",
|
||||
"Hardware",
|
||||
"Maintenance",
|
||||
"Open",
|
||||
3,
|
||||
3,
|
||||
5,
|
||||
9,
|
||||
"Pool plpSSDPool is at 86%. Grow or move guests before it reaches 90%.",
|
||||
),
|
||||
(
|
||||
"[switch-02][auto][network] Link down on uplink port 14 [single-node][production][problem]",
|
||||
"Network",
|
||||
"Problem",
|
||||
"Open",
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
"Port 14 (10GbE uplink to node-03) went down 2 hours ago. Traffic failed over to the backup path. Cable or SFP+ suspected.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][software] Upgrade Proxmox VE 8.3 to 8.4 [cluster-wide][production][upgrade]",
|
||||
"Software",
|
||||
"Upgrade",
|
||||
"Pending",
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
70,
|
||||
"Rolling upgrade, one node at a time. Blocked until the Ceph warning on osd.9 is resolved.",
|
||||
),
|
||||
(
|
||||
"[monitor-01][manual][software] Renew TLS certificate for status page [single-node][production][maintenance]",
|
||||
"Software",
|
||||
"Maintenance",
|
||||
"In Progress",
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
30,
|
||||
"Certificate expires in 12 days. Renewal job failed on the DNS challenge.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][security] Rotate API keys used by monitoring agents [cluster-wide][production][task]",
|
||||
"Security",
|
||||
"Task",
|
||||
"Open",
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
48,
|
||||
"Quarterly rotation. Keys: hwmonDaemon, gandalf, pulse workers.",
|
||||
),
|
||||
(
|
||||
"[node-01][auto][hardware] Drive SN-88C0D2 has SMART issues [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Closed",
|
||||
2,
|
||||
2,
|
||||
5,
|
||||
150,
|
||||
"Replaced during maintenance. OSD rebuilt, cluster healthy.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][configuration] Add Prometheus scrape target for new exporter [cluster-wide][production][task]",
|
||||
"General",
|
||||
"Task",
|
||||
"Closed",
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
200,
|
||||
"Added and verified in Targets.",
|
||||
),
|
||||
(
|
||||
"[node-02][manual][hardware] Replace failed NVMe in bay 2 [single-node][production][maintenance]",
|
||||
"Hardware",
|
||||
"Maintenance",
|
||||
"Pending",
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
52,
|
||||
"Replacement ordered. Awaiting delivery.",
|
||||
),
|
||||
(
|
||||
"[storage-01][auto][hardware] Memory ECC correctable errors increasing [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Open",
|
||||
3,
|
||||
None,
|
||||
5,
|
||||
14,
|
||||
"EDAC reports 41 correctable errors on DIMM_B1 in 24 hours.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][software] LXC 134 has no log retention configured [single-node][production][maintenance]",
|
||||
"Software",
|
||||
"Maintenance",
|
||||
"Open",
|
||||
4,
|
||||
None,
|
||||
3,
|
||||
96,
|
||||
"Log volume grows without bound. Add retention and a compactor.",
|
||||
),
|
||||
(
|
||||
"[backup-01][manual][software] Verify weekly offsite backup restore [single-node][production][task]",
|
||||
"General",
|
||||
"Task",
|
||||
"In Progress",
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
40,
|
||||
"Restore one guest from the weekly backup and confirm it boots.",
|
||||
),
|
||||
(
|
||||
"[switch-01][manual][network] Move camera VLAN to dedicated port group [single-node][testing][configuration]",
|
||||
"Network",
|
||||
"Task",
|
||||
"Pending",
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
120,
|
||||
"Needs a maintenance window.",
|
||||
),
|
||||
(
|
||||
"[node-03][auto][hardware] Fan 2 speed below threshold [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Closed",
|
||||
3,
|
||||
1,
|
||||
5,
|
||||
310,
|
||||
"Fan replaced.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][software] Document UPS shutdown order [cluster-wide][production][task]",
|
||||
"General",
|
||||
"Task",
|
||||
"Closed",
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
400,
|
||||
"Documented in the wiki.",
|
||||
),
|
||||
(
|
||||
"[node-02][auto][hardware] Drive SN-1B77E4 temperature above 55C [single-node][production][problem]",
|
||||
"Hardware",
|
||||
"Problem",
|
||||
"Open",
|
||||
4,
|
||||
None,
|
||||
5,
|
||||
6,
|
||||
"Drive temperature 57C, threshold 55C.",
|
||||
),
|
||||
(
|
||||
"[proxmox-cluster][manual][security] Review SSH access for decommissioned hosts [cluster-wide][production][task]",
|
||||
"Security",
|
||||
"Task",
|
||||
"Pending",
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
88,
|
||||
"Remove stale authorized_keys entries.",
|
||||
),
|
||||
(
|
||||
"[monitor-01][manual][software] Grafana alert rules need review [single-node][production][maintenance]",
|
||||
"Software",
|
||||
"Maintenance",
|
||||
"Open",
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
60,
|
||||
"Several rules have no contact point.",
|
||||
),
|
||||
]
|
||||
tids = []
|
||||
for i, t in enumerate(T):
|
||||
tid = str(100000000 + random.randint(1000000, 899999999))
|
||||
tids.append(tid)
|
||||
title, cat, typ, status, pr, asg, cr, age, desc = t
|
||||
closed = ts(max(age - 5, 1)) if status == "Closed" else "NULL"
|
||||
sql.append(
|
||||
f"INSERT INTO tickets(ticket_id,title,category,type,status,description,created_at,updated_at,closed_at,priority,hash,created_by,updated_by,assigned_to) VALUES({q(tid)},{q(title)},{q(cat)},{q(typ)},{q(status)},{q(desc)},{ts(age)},{ts(max(age-3,1))},{closed},{pr},{q(hashlib.sha256(title.encode()).hexdigest())},{cr},{cr},{asg if asg else 'NULL'});"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({cr},'create','ticket',{q(tid)},{q(json.dumps({'title':title}))},{ts(age)});"
|
||||
)
|
||||
if asg:
|
||||
sql.append(
|
||||
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES(1,'assign','ticket',{q(tid)},{q(json.dumps({'assigned_to':{'from':'','to':users[asg-1][2]}}))},{ts(max(age-1,1))});"
|
||||
)
|
||||
if status != "Open":
|
||||
sql.append(
|
||||
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({asg or 1},'status_change','ticket',{q(tid)},{q(json.dumps({'status':{'from':'Open','to':status}}))},{ts(max(age-2,1))});"
|
||||
)
|
||||
C = {
|
||||
0: [
|
||||
(2, "Confirmed on the node. Pulling the drive from the pool tonight and ordering a replacement.", 2),
|
||||
(1, "Approved. Use the spare from the shelf, then reorder.", 1),
|
||||
],
|
||||
2: [
|
||||
(
|
||||
1,
|
||||
"Slow ops are isolated to osd.9. `ceph tell osd.9 dump_historic_slow_ops` shows long waits on the DB device.",
|
||||
20,
|
||||
),
|
||||
(2, "Marked the OSD out for now. Recovery is progressing.", 12),
|
||||
],
|
||||
4: [(1, "Failover worked as designed. Swapping the SFP+ module first.", 1)],
|
||||
5: [(2, "Waiting for the Ceph warning to clear before starting the first node.", 30)],
|
||||
6: [(4, "DNS challenge fails because the API token expired. Generated a new one.", 8)],
|
||||
}
|
||||
for idx, cs in C.items():
|
||||
for uid, txt, age in cs:
|
||||
sql.append(
|
||||
f"INSERT INTO ticket_comments(ticket_id,user_name,comment_text,created_at,markdown_enabled,user_id) VALUES({q(tids[idx])},{q(users[uid-1][2])},{q(txt)},{ts(age)},1,{uid});"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({uid},'comment','comment','0',{q(json.dumps({'ticket_id':tids[idx]}))},{ts(age)});"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[5])},{q(tids[2])},'blocked_by',1);"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[0])},{q(tids[8])},'relates_to',1);"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[4])},{q(tids[14])},'relates_to',1);"
|
||||
)
|
||||
for tid in (tids[0], tids[2], tids[4]):
|
||||
sql.append(f"INSERT INTO ticket_watchers(ticket_id,user_id) VALUES({q(tid)},1);")
|
||||
for n, p, s in (
|
||||
("hwmonDaemon (prod)", "hwm_9f3a", "read_write"),
|
||||
("gandalf", "gnd_71c2", "read_write"),
|
||||
("readonly-dashboard", "ro_55de", "read"),
|
||||
):
|
||||
sql.append(
|
||||
f"INSERT INTO api_keys(key_name,key_hash,key_prefix,scope,created_by,last_used) VALUES({q(n)},{q(hashlib.sha256(p.encode()).hexdigest())},{q(p)},{q(s)},1,{ts(2)});"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[proxmox-cluster][auto][software] Monthly patch review [cluster-wide][production][maintenance]','Review pending updates and schedule a window.','Software','Maintenance',4,'monthly',1,{ts(-200)},1);"
|
||||
)
|
||||
sql.append(
|
||||
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[backup-01][auto][software] Weekly restore test [single-node][production][task]','Restore one guest and verify.','General','Task',4,'weekly',1,{ts(-60)},1);"
|
||||
)
|
||||
open(D + "/seed.sql", "w").write("\n".join(sql))
|
||||
print(len(T), "tickets; ids sample", tids[:3])
|
||||