Compare commits

...
Author SHA1 Message Date
jared a7f684626c Fix lint: make scripts/demo/router.php PSR-12 compliant; tidy demo scripts
Lint / PHP (phpcs PSR-12) (push) Successful in 33s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m14s
Lint / Deploy (push) Successful in 3s
2026-10-03 16:59:02 -04:00
jared 051ef6c915 README: add walkthrough GIF, gallery and architecture diagram; add demo seed and capture scripts
Lint / PHP (phpcs PSR-12) (push) Failing after 31s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Deploy (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m17s
Lint / Notify on failure (push) Successful in 8s
2026-10-03 01:02:55 -04:00
jared 13a0612f23 Fix migration runner: strip -- comments before splitting statements
A semicolon inside a SQL comment (as in 001 and 003) cut statements in half and
made those migrations fail on a fresh database.
2026-10-03 01:02:55 -04:00
jared 4885848375 Add MIT license
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m20s
Lint / Deploy (push) Successful in 2s
2026-10-03 00:16:58 -04:00
23 changed files with 636 additions and 2 deletions
+1
View File
@@ -9,3 +9,4 @@ uploads/*
!uploads/.htaccess
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
vendor/
scripts/demo/__pycache__/
+21
View File
@@ -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.
+56
View File
@@ -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
![Tinker Tickets walkthrough: dashboard, kanban, ticket detail with SLA timer, comments, dependencies, activity timeline, command palette and workflow designer](docs/img/demo.gif)
## 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.
![Dashboard](docs/img/dashboard.png)
**Kanban view** with per-column counts and priority-coloured cards:
![Kanban board](docs/img/kanban.png)
**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace:
![Ticket with SLA banner](docs/img/ticket-sla.png)
| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) |
|---|---|
| ![Comments](docs/img/ticket-comments.png) | ![Dependencies](docs/img/ticket-dependencies.png) |
**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments):
![Activity timeline](docs/img/ticket-activity.png)
**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets:
![Command palette](docs/img/command-palette.png)
**Admin tools:**
| Workflow designer | API keys (scoped, revocable) |
|---|---|
| ![Workflow designer](docs/img/admin-workflow.png) | ![API keys](docs/img/admin-api-keys.png) |
| **Audit log** | **Recurring tickets** |
| ![Audit log](docs/img/admin-audit-log.png) | ![Recurring tickets](docs/img/admin-recurring.png) |
**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle):
![Dashboard, light theme](docs/img/dashboard-light.png)
<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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+5 -2
View File
@@ -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);
+19
View File
@@ -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`.
+174
View File
@@ -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())
+16
View File
@@ -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';
+344
View File
@@ -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])