feat: incident ticket export — Notion and Jira integration (#12)

- app/services/ticket_export.py: plugin-dispatch architecture; Notion
  exporter (Notion API v1, blocks-based, 50 entry cap, 2000-char
  truncation per block); Jira exporter (REST API v3, Basic Auth, ADF
  description, configurable issue type defaulting to Bug)
- app/rest.py: POST /api/incidents/{id}/export endpoint; Notion/Jira
  credential fields added to SettingsBody and PATCH /api/settings handler
- web/src/views/IncidentsView.vue: "Export ticket ▾" dropdown in
  incident detail drawer — click-outside close, inline URL link on success
- web/src/views/SettingsView.vue: Ticket Trackers section with Notion
  token + database ID, Jira URL/email/token/project/issue-type; show/hide
  for secret fields
- tests/test_ticket_export.py: 17 tests covering dispatch, Notion
  success/error/config/payload/truncation paths, Jira success/error/
  auth/project/summary/default-issue-type
This commit is contained in:
pyr0ball 2026-06-14 15:46:11 -07:00
parent b8f766fb74
commit 6039ab2464
5 changed files with 672 additions and 2 deletions

View file

@ -307,6 +307,13 @@ class SettingsBody(BaseModel):
pihole_api_key: str | None = None
router_source_ids: str | None = None
device_names: str | None = None
notion_token: str | None = None
notion_database_id: str | None = None
jira_url: str | None = None
jira_email: str | None = None
jira_api_token: str | None = None
jira_project_key: str | None = None
jira_issue_type: str | None = None
class IncidentCreate(BaseModel):
@ -618,6 +625,20 @@ def patch_settings(body: SettingsBody) -> dict:
prefs["router_source_ids"] = body.router_source_ids
if body.device_names is not None:
prefs["device_names"] = body.device_names
if body.notion_token is not None:
prefs["notion_token"] = body.notion_token
if body.notion_database_id is not None:
prefs["notion_database_id"] = body.notion_database_id
if body.jira_url is not None:
prefs["jira_url"] = body.jira_url
if body.jira_email is not None:
prefs["jira_email"] = body.jira_email
if body.jira_api_token is not None:
prefs["jira_api_token"] = body.jira_api_token
if body.jira_project_key is not None:
prefs["jira_project_key"] = body.jira_project_key
if body.jira_issue_type is not None:
prefs["jira_issue_type"] = body.jira_issue_type
_save_prefs(prefs)
return prefs
@ -1325,6 +1346,41 @@ def send_incident_bundle(incident_id: str, sanitize: bool = False) -> dict:
raise HTTPException(status_code=502, detail=f"Send failed: {exc}") from exc
class TicketExportRequest(BaseModel):
target: str # "notion" | "jira"
@router.post("/api/incidents/{incident_id}/export")
def export_incident_ticket(incident_id: str, body: TicketExportRequest) -> dict:
"""Push an incident to an external ticket tracker (Notion or Jira)."""
from app.services.ticket_export import export_incident, available_targets
incident = get_incident(INCIDENTS_DB_PATH, incident_id)
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
if body.target not in available_targets():
raise HTTPException(status_code=422, detail=f"Unknown target. Supported: {available_targets()}")
prefs = _load_prefs()
config = {k: prefs.get(k, "") for k in (
"notion_token", "notion_database_id",
"jira_url", "jira_email", "jira_api_token", "jira_project_key", "jira_issue_type",
)}
from app.services.incidents import get_incident_entries
raw_entries = get_incident_entries(DB_PATH, incident)
entries = [dataclasses.asdict(e) for e in raw_entries]
incident_dict = dataclasses.asdict(incident)
try:
result = export_incident(body.target, incident_dict, entries, config)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"target": body.target, "url": result["url"], "ticket_id": result["ticket_id"]}
@router.post("/api/bundles")
def receive_bundle(bundle: dict) -> dict:
record = store_bundle(INCIDENTS_DB_PATH, bundle)

View file

@ -0,0 +1,213 @@
"""Incident ticket export — push Turnstone incidents to external trackers.
Supported targets: "notion", "jira"
Each exporter receives the incident dict and a list of log entry dicts,
and returns {"url": str, "ticket_id": str}.
"""
from __future__ import annotations
import json
from typing import Any
import httpx
# ---------------------------------------------------------------------------
# Notion exporter
# ---------------------------------------------------------------------------
def _notion_export(
incident: dict[str, Any],
entries: list[dict[str, Any]],
token: str,
database_id: str,
) -> dict[str, str]:
"""Create a Notion page in *database_id* from an incident.
Notion block types used: heading_2, bulleted_list_item, paragraph.
Rich text max length is 2000 chars per block.
"""
if not token or not database_id:
raise ValueError("Notion not configured — set notion_token and notion_database_id in Settings")
def _text(s: str, bold: bool = False) -> dict:
chunk: dict[str, Any] = {"type": "text", "text": {"content": s[:2000]}}
if bold:
chunk["annotations"] = {"bold": True}
return chunk
log_blocks: list[dict] = []
for e in entries[:50]: # Notion has page size limits
line = f"[{e.get('severity') or '?'}] {e.get('source_id', '')}{e.get('text', '')[:160]}"
log_blocks.append({"object": "block", "type": "bulleted_list_item",
"bulleted_list_item": {"rich_text": [_text(line)]}})
sev = incident.get("severity", "medium").upper()
issue_type = incident.get("issue_type") or ""
window = f"{incident.get('started_at') or '?'}{incident.get('ended_at') or 'ongoing'}"
children: list[dict] = [
{"object": "block", "type": "heading_2",
"heading_2": {"rich_text": [_text("Incident Details", bold=True)]}},
{"object": "block", "type": "paragraph",
"paragraph": {"rich_text": [
_text("Severity: ", bold=True), _text(sev),
_text(" Type: ", bold=True), _text(issue_type),
_text(" Window: ", bold=True), _text(window),
]}},
]
if incident.get("notes"):
children.append({"object": "block", "type": "paragraph",
"paragraph": {"rich_text": [_text("Notes: ", bold=True), _text(incident["notes"])]}})
children.append({"object": "block", "type": "heading_2",
"heading_2": {"rich_text": [_text("Log Evidence")]}})
children.extend(log_blocks)
payload = {
"parent": {"database_id": database_id},
"properties": {
"title": {"title": [_text(incident.get("label", "Unnamed Incident"))]},
},
"children": children,
}
resp = httpx.post(
"https://api.notion.com/v1/pages",
headers={
"Authorization": f"Bearer {token}",
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
json=payload,
timeout=15,
)
if not resp.is_success:
raise RuntimeError(f"Notion API error {resp.status_code}: {resp.text[:300]}")
page = resp.json()
page_id = page["id"]
url = page.get("url") or f"https://notion.so/{page_id.replace('-', '')}"
return {"url": url, "ticket_id": page_id}
# ---------------------------------------------------------------------------
# Jira exporter
# ---------------------------------------------------------------------------
def _jira_export(
incident: dict[str, Any],
entries: list[dict[str, Any]],
jira_url: str,
email: str,
api_token: str,
project_key: str,
issue_type: str = "Bug",
) -> dict[str, str]:
"""Create a Jira issue via REST API v3 (cloud or Server 8.4+)."""
if not jira_url or not email or not api_token or not project_key:
raise ValueError("Jira not configured — set jira_url, jira_email, jira_api_token, and jira_project_key in Settings")
base = jira_url.rstrip("/")
sev = incident.get("severity", "medium").upper()
inc_type = incident.get("issue_type") or "incident"
window = f"{incident.get('started_at') or '?'}{incident.get('ended_at') or 'ongoing'}"
log_lines = "\n".join(
f"[{e.get('severity') or '?'}] {e.get('source_id', '')}{e.get('text', '')[:160]}"
for e in entries[:40]
)
description = (
f"*Severity:* {sev} | *Type:* {inc_type} | *Window:* {window}\n\n"
+ (f"*Notes:* {incident['notes']}\n\n" if incident.get("notes") else "")
+ "h2. Log Evidence\n\n{{code}}\n" + log_lines + "\n{{code}}"
)
# Jira REST v3 uses Atlassian Document Format for description
adf_body = {
"type": "doc",
"version": 1,
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": description}]},
],
}
payload = {
"fields": {
"project": {"key": project_key},
"summary": incident.get("label", "Unnamed Incident"),
"issuetype": {"name": issue_type},
"description": adf_body,
}
}
import base64 as _b64
creds = _b64.b64encode(f"{email}:{api_token}".encode()).decode()
resp = httpx.post(
f"{base}/rest/api/3/issue",
headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json=payload,
timeout=15,
)
if not resp.is_success:
raise RuntimeError(f"Jira API error {resp.status_code}: {resp.text[:300]}")
data = resp.json()
issue_key = data["key"]
url = f"{base}/browse/{issue_key}"
return {"url": url, "ticket_id": issue_key}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_EXPORTERS = {
"notion": _notion_export,
"jira": _jira_export,
}
def available_targets() -> list[str]:
return list(_EXPORTERS.keys())
def export_incident(
target: str,
incident: dict[str, Any],
entries: list[dict[str, Any]],
config: dict[str, str],
) -> dict[str, str]:
"""Dispatch to the appropriate exporter.
*config* is pulled from the settings pref dict callers pass the relevant
subset so this service stays stateless and testable.
Returns {"url": str, "ticket_id": str}.
Raises ValueError for unknown target or missing config.
Raises RuntimeError on API-level failures.
"""
if target not in _EXPORTERS:
raise ValueError(f"Unknown ticket target: {target!r}. Supported: {list(_EXPORTERS)}")
if target == "notion":
return _notion_export(
incident, entries,
token=config.get("notion_token", ""),
database_id=config.get("notion_database_id", ""),
)
if target == "jira":
return _jira_export(
incident, entries,
jira_url=config.get("jira_url", ""),
email=config.get("jira_email", ""),
api_token=config.get("jira_api_token", ""),
project_key=config.get("jira_project_key", ""),
issue_type=config.get("jira_issue_type", "Bug"),
)
raise ValueError(f"Unhandled target: {target!r}")

224
tests/test_ticket_export.py Normal file
View file

@ -0,0 +1,224 @@
"""Tests for ticket_export service — Notion and Jira exporters."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
INCIDENT = {
"id": "inc-1",
"label": "Redis OOM — key eviction flood",
"issue_type": "memory",
"started_at": "2026-06-01T03:00:00Z",
"ended_at": "2026-06-01T03:45:00Z",
"notes": "Triggered by batch job at 03:00",
"severity": "high",
}
ENTRIES = [
{"entry_id": "e1", "source_id": "host:redis", "severity": "ERROR", "text": "maxmemory reached, evicting keys"},
{"entry_id": "e2", "source_id": "host:app", "severity": "WARN", "text": "Redis NOEVICTION response"},
]
def _mock_response(status_code: int, body: dict):
resp = MagicMock()
resp.is_success = (status_code < 400)
resp.status_code = status_code
resp.json.return_value = body
resp.text = str(body)
return resp
# ---------------------------------------------------------------------------
# available_targets
# ---------------------------------------------------------------------------
def test_available_targets_lists_known_integrations():
from app.services.ticket_export import available_targets
targets = available_targets()
assert "notion" in targets
assert "jira" in targets
# ---------------------------------------------------------------------------
# export_incident dispatch
# ---------------------------------------------------------------------------
def test_export_incident_raises_for_unknown_target():
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Unknown ticket target"):
export_incident("linear", INCIDENT, ENTRIES, {})
# ---------------------------------------------------------------------------
# Notion exporter
# ---------------------------------------------------------------------------
class TestNotionExport:
def test_successful_export_returns_url_and_id(self):
from app.services.ticket_export import export_incident
page_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
mock_resp = _mock_response(200, {"id": page_id, "url": f"https://notion.so/{page_id}"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
result = export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc123",
"notion_database_id": "db-id-xyz",
})
assert result["ticket_id"] == page_id
assert "notion.so" in result["url"]
mock_post.assert_called_once()
def test_raises_value_error_when_not_configured(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Notion not configured"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "",
"notion_database_id": "db-id",
})
def test_raises_value_error_when_database_id_missing(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Notion not configured"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc",
"notion_database_id": "",
})
def test_raises_runtime_error_on_api_error(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(401, {"message": "Unauthorized"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
with pytest.raises(RuntimeError, match="Notion API error 401"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "bad-token",
"notion_database_id": "db-id",
})
def test_sends_correct_database_id(self):
from app.services.ticket_export import export_incident
db_id = "my-database-uuid"
mock_resp = _mock_response(200, {"id": "page-id", "url": "https://notion.so/page-id"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc123",
"notion_database_id": db_id,
})
call_kwargs = mock_post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs.args[1] if len(call_kwargs.args) > 1 else call_kwargs.kwargs["json"]
assert payload["parent"]["database_id"] == db_id
def test_incident_label_becomes_page_title(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
payload = mock_post.call_args.kwargs["json"]
title_text = payload["properties"]["title"]["title"][0]["text"]["content"]
assert INCIDENT["label"] in title_text
def test_url_falls_back_to_constructed_url(self):
from app.services.ticket_export import export_incident
page_id = "abc123"
mock_resp = _mock_response(200, {"id": page_id}) # no 'url' in response
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
result = export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
assert "notion.so" in result["url"] or page_id in result["url"]
def test_long_text_truncated_to_notion_limit(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"})
long_entries = [{"entry_id": f"e{i}", "source_id": "host:svc", "severity": "ERROR",
"text": "x" * 300} for i in range(60)]
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, long_entries, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
payload = mock_post.call_args.kwargs["json"]
for block in payload.get("children", []):
for rt in block.get("bulleted_list_item", {}).get("rich_text", []):
assert len(rt["text"]["content"]) <= 2000
# ---------------------------------------------------------------------------
# Jira exporter
# ---------------------------------------------------------------------------
class TestJiraExport:
_config = {
"jira_url": "https://myorg.atlassian.net",
"jira_email": "ops@example.com",
"jira_api_token": "ATATT3xFfGF0abc123",
"jira_project_key": "OPS",
"jira_issue_type": "Bug",
}
def test_successful_export_returns_url_and_key(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"id": "10042", "key": "OPS-42", "self": "https://myorg.atlassian.net/rest/api/3/issue/10042"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
result = export_incident("jira", INCIDENT, ENTRIES, self._config)
assert result["ticket_id"] == "OPS-42"
assert "OPS-42" in result["url"]
assert "myorg.atlassian.net" in result["url"]
def test_raises_value_error_when_not_configured(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Jira not configured"):
export_incident("jira", INCIDENT, ENTRIES, {
"jira_url": "",
"jira_email": "a@b.com",
"jira_api_token": "tok",
"jira_project_key": "OPS",
})
def test_raises_runtime_error_on_api_error(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(403, {"errorMessages": ["Forbidden"]})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
with pytest.raises(RuntimeError, match="Jira API error 403"):
export_incident("jira", INCIDENT, ENTRIES, self._config)
def test_sends_basic_auth_header(self):
from app.services.ticket_export import export_incident
import base64
mock_resp = _mock_response(201, {"key": "OPS-1", "id": "1"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
call_kwargs = mock_post.call_args.kwargs
auth_header = call_kwargs["headers"]["Authorization"]
assert auth_header.startswith("Basic ")
decoded = base64.b64decode(auth_header[6:]).decode()
assert "ops@example.com" in decoded
def test_uses_correct_project_key(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"key": "OPS-7", "id": "7"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["project"]["key"] == "OPS"
def test_incident_label_becomes_summary(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"key": "OPS-8", "id": "8"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["summary"] == INCIDENT["label"]
def test_default_issue_type_is_bug(self):
from app.services.ticket_export import export_incident
config = {k: v for k, v in self._config.items() if k != "jira_issue_type"}
mock_resp = _mock_response(201, {"key": "OPS-9", "id": "9"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["issuetype"]["name"] == "Bug"

View file

@ -86,6 +86,29 @@
{{ sending ? 'Sending…' : 'Send Bundle' }}
</button>
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
<!-- Export to ticket tracker -->
<div class="relative" ref="exportMenuRef">
<button
@click="exportMenuOpen = !exportMenuOpen"
:disabled="exporting"
class="px-3 py-1.5 text-xs rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors disabled:opacity-40"
>{{ exporting ? 'Exporting…' : 'Export ticket ▾' }}</button>
<div
v-if="exportMenuOpen"
class="absolute right-0 top-full mt-1 w-32 bg-surface border border-surface-border rounded shadow-lg z-10"
>
<button
v-for="target in exportTargets"
:key="target.key"
@click="exportTicket(selected!.id, target.key)"
class="block w-full text-left px-3 py-2 text-xs text-text-primary hover:bg-surface-raised transition-colors"
>{{ target.label }}</button>
</div>
</div>
<span v-if="exportStatus" :class="exportStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs">
<a v-if="exportStatus.url" :href="exportStatus.url" target="_blank" rel="noopener" class="underline">{{ exportStatus.msg }}</a>
<span v-else>{{ exportStatus.msg }}</span>
</span>
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs ml-auto sm:ml-0"> close</button>
</div>
</div>
@ -149,7 +172,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import IncidentTimeline from '@/components/IncidentTimeline.vue'
@ -238,6 +261,47 @@ async function sendBundle(id: string) {
}
}
// ticket export
const exportTargets = [
{ key: 'notion', label: 'Notion' },
{ key: 'jira', label: 'Jira' },
]
const exporting = ref(false)
const exportMenuOpen = ref(false)
const exportMenuRef = ref<HTMLElement | null>(null)
const exportStatus = ref<{ ok: boolean; msg: string; url?: string } | null>(null)
function handleExportClickOutside(e: MouseEvent) {
if (exportMenuRef.value && !exportMenuRef.value.contains(e.target as Node)) {
exportMenuOpen.value = false
}
}
onMounted(() => { document.addEventListener('click', handleExportClickOutside) })
onBeforeUnmount(() => { document.removeEventListener('click', handleExportClickOutside) })
async function exportTicket(incident_id: string, target: string) {
exportMenuOpen.value = false
exporting.value = true
exportStatus.value = null
try {
const res = await fetch(`${BASE}/api/incidents/${incident_id}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
})
const data = await res.json()
if (res.ok) {
exportStatus.value = { ok: true, msg: `Created ${data.ticket_id}`, url: data.url }
} else {
exportStatus.value = { ok: false, msg: data.detail ?? 'Export failed' }
}
} catch {
exportStatus.value = { ok: false, msg: 'Network error' }
} finally {
exporting.value = false
}
}
// timeline interaction
const highlightIdx = ref<number | null>(null)

View file

@ -394,6 +394,88 @@
</button>
</div>
<!-- Ticket Trackers -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Ticket Trackers</h2>
<p class="text-text-dim text-xs mb-4">
Connect external issue trackers to export incidents with one click from the Incidents view.
</p>
<!-- Notion -->
<div class="mb-4">
<h3 class="text-text-primary text-xs font-medium mb-2">Notion</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Integration token</label>
<div class="relative">
<input v-model="prefs.notion_token" :type="showNotionToken ? 'text' : 'password'"
placeholder="secret_xxxxxxxxxxxx"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
<button @click="showNotionToken = !showNotionToken"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
{{ showNotionToken ? 'hide' : 'show' }}
</button>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Database ID</label>
<input v-model="prefs.notion_database_id" type="text"
placeholder="8-4-4-4-12 UUID from the database URL"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
</div>
<!-- Jira -->
<div class="mb-4">
<h3 class="text-text-primary text-xs font-medium mb-2">Jira</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Jira URL</label>
<input v-model="prefs.jira_url" type="url"
placeholder="https://yourorg.atlassian.net"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Account email</label>
<input v-model="prefs.jira_email" type="email"
placeholder="you@example.com"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">API token</label>
<div class="relative">
<input v-model="prefs.jira_api_token" :type="showJiraToken ? 'text' : 'password'"
placeholder="Atlassian API token"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
<button @click="showJiraToken = !showJiraToken"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
{{ showJiraToken ? 'hide' : 'show' }}
</button>
</div>
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Project key</label>
<input v-model="prefs.jira_project_key" type="text"
placeholder="OPS"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Issue type</label>
<input v-model="prefs.jira_issue_type" type="text"
placeholder="Bug"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
</div>
<button @click="saveTicketTrackers"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity">
Save tracker settings
</button>
<span v-if="ticketSaveStatus" :class="ticketSaveStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs ml-3">{{ ticketSaveStatus.msg }}</span>
</div>
<p
v-if="saveStatus"
role="status"
@ -432,6 +514,13 @@ interface Prefs {
pihole_api_key: string
router_source_ids: string
device_names: string
notion_token: string
notion_database_id: string
jira_url: string
jira_email: string
jira_api_token: string
jira_project_key: string
jira_issue_type: string
}
interface SshTarget {
@ -481,12 +570,15 @@ async function setTechLevel(level: 'homelab' | 'sysadmin' | 'executive') {
}
}
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '' })
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '', notion_token: '', notion_database_id: '', jira_url: '', jira_email: '', jira_api_token: '', jira_project_key: '', jira_issue_type: 'Bug' })
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
const showAddOverride = ref(false)
const showApiKey = ref(false)
const showPiholeKey = ref(false)
const showNotionToken = ref(false)
const showJiraToken = ref(false)
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
const ticketSaveStatus = ref<{ ok: boolean; msg: string } | null>(null)
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
// SSH targets
@ -633,6 +725,27 @@ async function testPihole() {
}
}
// --- Ticket tracker settings ---
async function saveTicketTrackers() {
ticketSaveStatus.value = null
try {
await patch({
notion_token: prefs.value.notion_token,
notion_database_id: prefs.value.notion_database_id,
jira_url: prefs.value.jira_url,
jira_email: prefs.value.jira_email,
jira_api_token: prefs.value.jira_api_token,
jira_project_key: prefs.value.jira_project_key,
jira_issue_type: prefs.value.jira_issue_type,
})
ticketSaveStatus.value = { ok: true, msg: 'Tracker settings saved' }
setTimeout(() => { ticketSaveStatus.value = null }, 2000)
} catch {
ticketSaveStatus.value = { ok: false, msg: 'Save failed — check server connection' }
}
}
// --- SSH target management ---
async function loadSshTargets() {