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

This commit is contained in:
2026-10-03 16:59:02 -04:00
parent 051ef6c915
commit a7f684626c
4 changed files with 515 additions and 132 deletions
+159 -63
View File
@@ -1,78 +1,174 @@
import asyncio, io, sys
# 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)
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)
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')
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 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()
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=[]
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)
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))
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()
if "gif" not in sys.argv:
await shots()
if "shots" not in sys.argv:
await gif()
asyncio.run(main())
+13 -3
View File
@@ -1,6 +1,16 @@
<?php
$p = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$f = __DIR__ . '/tinker' . $p;
if ($p !== '/' && is_file($f) && !str_ends_with($f, '.php')) { return false; }
/**
* 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';
+342 -66
View File
@@ -1,68 +1,344 @@
import subprocess, hashlib, random, json
from datetime import datetime, timedelta
random.seed(7)
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
import hashlib
import json
import pathlib
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."),
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),
]
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])
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])