fix(jira): correct browse URL, rename Procedure to Proof of Concept; feat(tempo): debug endpoint + UI
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Jira URL fix: - JiraLinkPanel now fetches the configured Jira base URL via getJiraConfig() instead of hardcoding https://jira.atlassian.com; falls back to the old value if config is not yet loaded Description fix: - _build_test_description: renamed 'h3. Procedure' -> 'h3. Proof of Concept' so the procedure/tool block maps to the correct Jira field label Tempo debug: - New POST /system/tempo-test endpoint: checks TEMPO_ENABLED, token, user jira_account_id, and makes a real API call; always returns HTTP 200 with status field (Cloudflare-safe) - docker-compose.prod.yml: added TEMPO_ENABLED, TEMPO_API_TOKEN, TEMPO_DEFAULT_WORK_TYPE env vars (default off, ready to enable) - SettingsPage: added 'Test Tempo Connection' button in Jira admin tab with clear feedback showing what's missing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -349,6 +349,71 @@ def test_jira_connection(
|
|||||||
return {"status": "error", "message": msg, "jira_url": jira_url}
|
return {"status": "error", "message": msg, "jira_url": jira_url}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /system/tempo-test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tempo-test")
|
||||||
|
def test_tempo_connection(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""Test the Tempo connection and report configuration status.
|
||||||
|
|
||||||
|
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||||
|
intercepts the response.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if not settings.TEMPO_ENABLED:
|
||||||
|
return {
|
||||||
|
"status": "disabled",
|
||||||
|
"message": "Tempo is not enabled. Set TEMPO_ENABLED=true and TEMPO_API_TOKEN in your environment.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not settings.TEMPO_API_TOKEN:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "TEMPO_API_TOKEN is empty. Add it to your environment.",
|
||||||
|
}
|
||||||
|
|
||||||
|
jira_account_id = getattr(current_user, "jira_account_id", None)
|
||||||
|
if not jira_account_id:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": (
|
||||||
|
"Your user has no Jira Account ID configured. "
|
||||||
|
"Set it in Settings → Profile → Jira Integration → Account ID."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tempoapiclient import client_v4 as tempo_client
|
||||||
|
tempo = tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
|
||||||
|
# Fetch current user's worklogs as a connectivity check (limit 1)
|
||||||
|
worklogs = tempo.get_worklogs_by_account_id(
|
||||||
|
account_id=jira_account_id,
|
||||||
|
dateFrom="2024-01-01",
|
||||||
|
dateTo="2024-01-02",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Tempo connected. Account ID: {jira_account_id}",
|
||||||
|
"worklogs_found": len(worklogs) if isinstance(worklogs, list) else "n/a",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
err = str(exc)
|
||||||
|
if "401" in err or "Unauthorized" in err:
|
||||||
|
msg = "Authentication failed (401). Check your TEMPO_API_TOKEN."
|
||||||
|
elif "403" in err or "Forbidden" in err:
|
||||||
|
msg = "Access denied (403). The Tempo token may not have the required permissions."
|
||||||
|
else:
|
||||||
|
msg = f"Tempo connection failed: {err}"
|
||||||
|
logger.warning("Tempo test connection failed: %s", err)
|
||||||
|
return {"status": "error", "message": msg}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /system/email-config
|
# GET /system/email-config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def _build_test_description(test: Test, technique: Optional[Technique]) -> str:
|
|||||||
"h3. Description",
|
"h3. Description",
|
||||||
test.description or "_No description provided._",
|
test.description or "_No description provided._",
|
||||||
"",
|
"",
|
||||||
"h3. Procedure",
|
"h3. Proof of Concept",
|
||||||
f"{{code}}{test.procedure_text or 'N/A'}{{code}}",
|
f"{{code}}{test.procedure_text or 'N/A'}{{code}}",
|
||||||
"",
|
"",
|
||||||
f"*Tool:* {test.tool_used or 'N/A'}",
|
f"*Tool:* {test.tool_used or 'N/A'}",
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ services:
|
|||||||
SECURE_COOKIES: ${SECURE_COOKIES:-false}
|
SECURE_COOKIES: ${SECURE_COOKIES:-false}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
# ── Tempo time-tracking (optional) ────────────────────────────────────
|
||||||
|
TEMPO_ENABLED: ${TEMPO_ENABLED:-false}
|
||||||
|
TEMPO_API_TOKEN: ${TEMPO_API_TOKEN:-}
|
||||||
|
TEMPO_DEFAULT_WORK_TYPE: ${TEMPO_DEFAULT_WORK_TYPE:-Red Team}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -188,3 +188,12 @@ export async function testJiraConnection(): Promise<{
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testTempoConnection(): Promise<{
|
||||||
|
status: "ok" | "error" | "disabled";
|
||||||
|
message?: string;
|
||||||
|
worklogs_found?: number | string;
|
||||||
|
}> {
|
||||||
|
const { data } = await client.post("/system/tempo-test");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type JiraLinkEntityType,
|
type JiraLinkEntityType,
|
||||||
type JiraIssueResult,
|
type JiraIssueResult,
|
||||||
} from "../api/jira";
|
} from "../api/jira";
|
||||||
|
import { getJiraConfig } from "../api/settings";
|
||||||
import { useDebounce } from "../hooks/useDebounce";
|
import { useDebounce } from "../hooks/useDebounce";
|
||||||
|
|
||||||
interface JiraLinkPanelProps {
|
interface JiraLinkPanelProps {
|
||||||
@@ -49,6 +50,14 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
|
|||||||
|
|
||||||
// ── Queries ─────────────────────────────────────────────────────
|
// ── Queries ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { data: jiraConfig } = useQuery({
|
||||||
|
queryKey: ["jira-config"],
|
||||||
|
queryFn: getJiraConfig,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jiraBaseUrl = jiraConfig?.url?.replace(/\/$/, "") ?? "https://jira.atlassian.com";
|
||||||
|
|
||||||
const { data: links = [], isLoading: isLoadingLinks } = useQuery({
|
const { data: links = [], isLoading: isLoadingLinks } = useQuery({
|
||||||
queryKey: ["jira-links", entityType, entityId],
|
queryKey: ["jira-links", entityType, entityId],
|
||||||
queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }),
|
queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }),
|
||||||
@@ -247,7 +256,7 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`https://jira.atlassian.com/browse/${link.jira_issue_key}`}
|
href={`${jiraBaseUrl}/browse/${link.jira_issue_key}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="Open in Jira"
|
title="Open in Jira"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
getJiraConfig,
|
getJiraConfig,
|
||||||
updateJiraConfig,
|
updateJiraConfig,
|
||||||
testJiraConnection,
|
testJiraConnection,
|
||||||
|
testTempoConnection,
|
||||||
type EmailConfigUpdate,
|
type EmailConfigUpdate,
|
||||||
type WebhookCreate,
|
type WebhookCreate,
|
||||||
type WebhookOut,
|
type WebhookOut,
|
||||||
@@ -1024,6 +1025,8 @@ function JiraConfigSection() {
|
|||||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||||
const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
const [tempoResult, setTempoResult] = useState<string | null>(null);
|
||||||
|
const [tempoError, setTempoError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: cfg, isLoading } = useQuery({
|
const { data: cfg, isLoading } = useQuery({
|
||||||
queryKey: ["jira-config"],
|
queryKey: ["jira-config"],
|
||||||
@@ -1061,6 +1064,23 @@ function JiraConfigSection() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tempoTestMut = useMutation({
|
||||||
|
mutationFn: testTempoConnection,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.status === "ok") {
|
||||||
|
setTempoResult(data.message ?? "Connected");
|
||||||
|
setTempoError(null);
|
||||||
|
} else {
|
||||||
|
setTempoError(data.message ?? "Tempo test failed");
|
||||||
|
setTempoResult(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setTempoError(err.message || "Tempo test failed");
|
||||||
|
setTempoResult(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -1139,9 +1159,9 @@ function JiraConfigSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test connection */}
|
{/* Test Jira connection */}
|
||||||
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
|
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium text-gray-300">Test Connection</p>
|
<p className="text-sm font-medium text-gray-300">Test Jira Connection</p>
|
||||||
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
|
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
|
||||||
Uses your personal Jira API token (configured in the Profile tab)
|
Uses your personal Jira API token (configured in the Profile tab)
|
||||||
</div>
|
</div>
|
||||||
@@ -1159,7 +1179,7 @@ function JiraConfigSection() {
|
|||||||
) : (
|
) : (
|
||||||
<TestTube className="h-4 w-4" />
|
<TestTube className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Test Connection
|
Test Jira Connection
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
@@ -1175,6 +1195,43 @@ function JiraConfigSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Test Tempo connection */}
|
||||||
|
<div className="mt-2 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium text-gray-300">Test Tempo Connection</p>
|
||||||
|
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
|
||||||
|
Requires <code className="font-mono">TEMPO_ENABLED=true</code> and <code className="font-mono">TEMPO_API_TOKEN</code> set in the server environment, plus your Jira Account ID in the Profile tab.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTempoResult(null);
|
||||||
|
setTempoError(null);
|
||||||
|
tempoTestMut.mutate();
|
||||||
|
}}
|
||||||
|
disabled={tempoTestMut.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-purple-700 px-4 py-2 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{tempoTestMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Test Tempo Connection
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{tempoResult && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-sm text-emerald-300">
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{tempoResult}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tempoError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-sm text-red-300">
|
||||||
|
<XCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{tempoError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user