feat(phase-35): Jira + Tempo integration with internal worklogs
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Full Jira/Tempo pipeline: link Aegis entities to Jira issues, auto-sync
status hourly, log time internally with integrity hashing, and optionally
push worklogs to Tempo.
- 1.1 JiraLink model + Worklog model: Alembic migration b020 with indexes,
enums (jiralinkentitytype, jirasyncdirection), and integrity_hash column
- 1.2 Jira service: atlassian-python-api wrapper with lazy singleton client,
search/create/sync operations, feature-flagged via JIRA_ENABLED
- 1.3 Jira router: CRUD endpoints for /jira/links, /jira/search,
/jira/create-issue with audit logging and entity-to-issue auto-creation
- 1.4 Tempo service: worklog push via tempo-api-python-client, auto-log from
test completions when TEMPO_ENABLED, graceful fallback on failure
- 1.5 Worklog service + router: immutable internal time records with SHA-256
integrity hash, CRUD at /worklogs, /worklogs/{id}/verify endpoint
- 1.6 Frontend: JiraLinkPanel component (search, link, sync, unlink) and
WorklogTimeline component (timeline view, manual log form) integrated into
TestDetailPage sidebar, CampaignDetailPage grid, TechniqueDetailPage
- 1.7 Jira sync job: APScheduler hourly job syncs all links from Jira,
registered in background scheduler alongside existing jobs
This commit is contained in:
90
frontend/src/api/jira.ts
Normal file
90
frontend/src/api/jira.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import client from "./client";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
|
||||
export type JiraSyncDirection = "aegis_to_jira" | "jira_to_aegis" | "bidirectional";
|
||||
|
||||
export interface JiraLink {
|
||||
id: string;
|
||||
entity_type: JiraLinkEntityType;
|
||||
entity_id: string;
|
||||
jira_issue_key: string;
|
||||
jira_issue_id: string | null;
|
||||
jira_project_key: string | null;
|
||||
jira_status: string | null;
|
||||
jira_priority: string | null;
|
||||
jira_assignee: string | null;
|
||||
jira_story_points: string | null;
|
||||
last_synced_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface JiraIssueResult {
|
||||
issue_key: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
assignee: string | null;
|
||||
priority: string | null;
|
||||
}
|
||||
|
||||
export interface JiraLinkCreatePayload {
|
||||
entity_type: JiraLinkEntityType;
|
||||
entity_id: string;
|
||||
jira_issue_key: string;
|
||||
sync_direction?: JiraSyncDirection;
|
||||
}
|
||||
|
||||
// ── API Functions ───────────────────────────────────────────────────
|
||||
|
||||
/** Search Jira issues by JQL or free text. */
|
||||
export async function searchJiraIssues(
|
||||
q: string,
|
||||
maxResults: number = 10,
|
||||
): Promise<JiraIssueResult[]> {
|
||||
const { data } = await client.get<JiraIssueResult[]>("/jira/search", {
|
||||
params: { q, max_results: maxResults },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a new Jira link for an Aegis entity. */
|
||||
export async function createJiraLink(
|
||||
payload: JiraLinkCreatePayload,
|
||||
): Promise<JiraLink> {
|
||||
const { data } = await client.post<JiraLink>("/jira/links", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** List Jira links, optionally filtered. */
|
||||
export async function listJiraLinks(params?: {
|
||||
entity_type?: JiraLinkEntityType;
|
||||
entity_id?: string;
|
||||
}): Promise<JiraLink[]> {
|
||||
const { data } = await client.get<JiraLink[]>("/jira/links", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Force sync a Jira link. */
|
||||
export async function syncJiraLink(
|
||||
linkId: string,
|
||||
): Promise<{ message: string; jira_status: string }> {
|
||||
const { data } = await client.post(`/jira/links/${linkId}/sync`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Delete a Jira link. */
|
||||
export async function deleteJiraLink(linkId: string): Promise<void> {
|
||||
await client.delete(`/jira/links/${linkId}`);
|
||||
}
|
||||
|
||||
/** Auto-create a Jira issue from an Aegis entity and link them. */
|
||||
export async function createIssueFromEntity(
|
||||
entityType: JiraLinkEntityType,
|
||||
entityId: string,
|
||||
): Promise<{ issue_key: string; link_id: string }> {
|
||||
const { data } = await client.post("/jira/create-issue", null, {
|
||||
params: { entity_type: entityType, entity_id: entityId },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
62
frontend/src/api/worklogs.ts
Normal file
62
frontend/src/api/worklogs.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import client from "./client";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface Worklog {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
user_id: string;
|
||||
activity_type: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration_seconds: number;
|
||||
description: string | null;
|
||||
tempo_synced: string | null;
|
||||
integrity_hash: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WorklogCreatePayload {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
activity_type: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration_seconds: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── API Functions ───────────────────────────────────────────────────
|
||||
|
||||
/** Create a manual worklog entry. */
|
||||
export async function createWorklog(
|
||||
payload: WorklogCreatePayload,
|
||||
): Promise<Worklog> {
|
||||
const { data } = await client.post<Worklog>("/worklogs", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** List worklogs with optional filters. */
|
||||
export async function listWorklogs(params?: {
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
user_id?: string;
|
||||
}): Promise<Worklog[]> {
|
||||
const { data } = await client.get<Worklog[]>("/worklogs", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Get a single worklog. */
|
||||
export async function getWorklog(worklogId: string): Promise<Worklog> {
|
||||
const { data } = await client.get<Worklog>(`/worklogs/${worklogId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Verify a worklog's integrity hash. */
|
||||
export async function verifyWorklogIntegrity(
|
||||
worklogId: string,
|
||||
): Promise<{ worklog_id: string; integrity_valid: boolean }> {
|
||||
const { data } = await client.get(`/worklogs/${worklogId}/verify`);
|
||||
return data;
|
||||
}
|
||||
274
frontend/src/components/JiraLinkPanel.tsx
Normal file
274
frontend/src/components/JiraLinkPanel.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ExternalLink,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
listJiraLinks,
|
||||
searchJiraIssues,
|
||||
createJiraLink,
|
||||
syncJiraLink,
|
||||
deleteJiraLink,
|
||||
type JiraLink,
|
||||
type JiraLinkEntityType,
|
||||
type JiraIssueResult,
|
||||
} from "../api/jira";
|
||||
import { useDebounce } from "../hooks/useDebounce";
|
||||
|
||||
interface JiraLinkPanelProps {
|
||||
entityType: JiraLinkEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
Highest: "text-red-400",
|
||||
High: "text-orange-400",
|
||||
Medium: "text-yellow-400",
|
||||
Low: "text-cyan-400",
|
||||
Lowest: "text-gray-400",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
"To Do": "bg-gray-700 text-gray-300",
|
||||
"In Progress": "bg-blue-900/50 text-blue-400",
|
||||
"Done": "bg-green-900/50 text-green-400",
|
||||
};
|
||||
|
||||
export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(searchQuery, 400);
|
||||
|
||||
// ── Queries ─────────────────────────────────────────────────────
|
||||
|
||||
const { data: links = [], isLoading: isLoadingLinks } = useQuery({
|
||||
queryKey: ["jira-links", entityType, entityId],
|
||||
queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }),
|
||||
});
|
||||
|
||||
const { data: searchResults = [], isFetching: isSearching } = useQuery({
|
||||
queryKey: ["jira-search", debouncedQuery],
|
||||
queryFn: () => searchJiraIssues(debouncedQuery),
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
});
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["jira-links", entityType, entityId],
|
||||
});
|
||||
};
|
||||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: (issueKey: string) =>
|
||||
createJiraLink({
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
jira_issue_key: issueKey,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setShowSearch(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (linkId: string) => syncJiraLink(linkId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (linkId: string) => deleteJiraLink(linkId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────────────
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string | null) => {
|
||||
if (!status) return "bg-gray-700 text-gray-400";
|
||||
return statusColors[status] || "bg-gray-700 text-gray-300";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Link2 className="h-5 w-5 text-blue-400" />
|
||||
Jira
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showSearch ? (
|
||||
<>
|
||||
<X className="h-3.5 w-3.5" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" /> Link Issue
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search panel */}
|
||||
{showSearch && (
|
||||
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search Jira issues (e.g. SEC-1234 or keyword)..."
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 py-2 pl-10 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{searchResults.map((issue: JiraIssueResult) => (
|
||||
<button
|
||||
key={issue.issue_key}
|
||||
onClick={() => linkMutation.mutate(issue.issue_key)}
|
||||
disabled={linkMutation.isPending}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-blue-400">
|
||||
{issue.issue_key}
|
||||
</span>
|
||||
<span className={`text-xs ${priorityColors[issue.priority || ""] || "text-gray-500"}`}>
|
||||
{issue.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-gray-300">
|
||||
{issue.summary}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`ml-2 shrink-0 rounded px-2 py-0.5 text-xs ${getStatusClass(issue.status)}`}>
|
||||
{issue.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debouncedQuery.length >= 2 && !isSearching && searchResults.length === 0 && (
|
||||
<p className="text-center text-xs text-gray-500">No issues found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked issues */}
|
||||
{isLoadingLinks ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-500 py-4">
|
||||
No Jira issues linked
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.map((link: JiraLink) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800/30 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm font-medium text-blue-400">
|
||||
{link.jira_issue_key}
|
||||
</span>
|
||||
{link.jira_status && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${getStatusClass(link.jira_status)}`}
|
||||
>
|
||||
{link.jira_status}
|
||||
</span>
|
||||
)}
|
||||
{link.jira_priority && (
|
||||
<span
|
||||
className={`text-xs ${priorityColors[link.jira_priority] || "text-gray-500"}`}
|
||||
>
|
||||
{link.jira_priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{link.jira_assignee && (
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Assignee: {link.jira_assignee}
|
||||
</p>
|
||||
)}
|
||||
{link.last_synced_at && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Synced: {formatDate(link.last_synced_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(link.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
title="Sync from Jira"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<a
|
||||
href={`https://jira.atlassian.com/browse/${link.jira_issue_key}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open in Jira"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(link.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Unlink"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
frontend/src/components/WorklogTimeline.tsx
Normal file
217
frontend/src/components/WorklogTimeline.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock, Plus, Loader2, ShieldCheck, ShieldAlert, X } from "lucide-react";
|
||||
import { listWorklogs, createWorklog, type Worklog } from "../api/worklogs";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
interface WorklogTimelineProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
|
||||
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
|
||||
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
|
||||
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
|
||||
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
|
||||
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
|
||||
};
|
||||
|
||||
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function WorklogTimeline({ entityType, entityId }: WorklogTimelineProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
activity_type: "red_team",
|
||||
duration_minutes: "60",
|
||||
description: "",
|
||||
});
|
||||
|
||||
// ── Query ───────────────────────────────────────────────────────
|
||||
|
||||
const { data: worklogs = [], isLoading } = useQuery({
|
||||
queryKey: ["worklogs", entityType, entityId],
|
||||
queryFn: () => listWorklogs({ entity_type: entityType, entity_id: entityId }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createWorklog({
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
activity_type: form.activity_type,
|
||||
started_at: new Date().toISOString(),
|
||||
duration_seconds: parseInt(form.duration_minutes, 10) * 60,
|
||||
description: form.description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["worklogs", entityType, entityId],
|
||||
});
|
||||
setShowForm(false);
|
||||
setForm({ activity_type: "red_team", duration_minutes: "60", description: "" });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Total time ──────────────────────────────────────────────────
|
||||
|
||||
const totalSeconds = worklogs.reduce(
|
||||
(sum: number, wl: Worklog) => sum + wl.duration_seconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Clock className="h-5 w-5 text-cyan-400" />
|
||||
Time Log
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalSeconds > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
{showForm ? (
|
||||
<>
|
||||
<X className="h-3.5 w-3.5" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" /> Log Time
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New worklog form */}
|
||||
{showForm && (
|
||||
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-400">Activity Type</label>
|
||||
<select
|
||||
value={form.activity_type}
|
||||
onChange={(e) => setForm({ ...form, activity_type: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
<option value="red_team">Red Team</option>
|
||||
<option value="blue_validation">Blue Validation</option>
|
||||
<option value="purple_review">Purple Review</option>
|
||||
<option value="reporting">Reporting</option>
|
||||
<option value="execution">Execution</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-400">Duration (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.duration_minutes}
|
||||
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-400">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="What did you work on?"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.duration_minutes}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save Worklog
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : worklogs.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-500 py-4">No time logged yet</p>
|
||||
) : (
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-gray-700" />
|
||||
|
||||
{worklogs.map((wl: Worklog) => {
|
||||
const style = activityColors[wl.activity_type] || defaultActivity;
|
||||
return (
|
||||
<div key={wl.id} className="relative flex gap-3 py-2">
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${style.icon}`}
|
||||
style={{ marginLeft: "6px" }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
|
||||
>
|
||||
{wl.activity_type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-200">
|
||||
{formatDuration(wl.duration_seconds)}
|
||||
</span>
|
||||
{wl.tempo_synced && (
|
||||
<span className="text-xs text-green-500" title="Synced to Tempo">
|
||||
<ShieldCheck className="inline h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{wl.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-400 truncate">
|
||||
{wl.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{formatDate(wl.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from "../api/campaigns";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import CampaignTimeline from "../components/CampaignTimeline";
|
||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||
import WorklogTimeline from "../components/WorklogTimeline";
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||
@@ -598,6 +600,12 @@ export default function CampaignDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jira & Worklogs */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<JiraLinkPanel entityType="campaign" entityId={campaignId!} />
|
||||
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
|
||||
</div>
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"
|
||||
import { getTemplatesByTechnique } from "../api/test-templates";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||
import type { TechniqueStatus, TestState, TestResult } from "../types/models";
|
||||
|
||||
const statusBadgeColors: Record<TechniqueStatus, string> = {
|
||||
@@ -447,6 +448,11 @@ export default function TechniqueDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jira Integration */}
|
||||
{technique && (
|
||||
<JiraLinkPanel entityType="technique" entityId={technique.id} />
|
||||
)}
|
||||
|
||||
{/* Template instantiation modal */}
|
||||
{templateFormId && technique && (
|
||||
<TestFromTemplateForm
|
||||
|
||||
@@ -24,6 +24,8 @@ import TestDetailHeader from "../components/test-detail/TestDetailHeader";
|
||||
import TeamTabs from "../components/test-detail/TeamTabs";
|
||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||
import WorklogTimeline from "../components/WorklogTimeline";
|
||||
|
||||
// ── Page Component ─────────────────────────────────────────────────
|
||||
|
||||
@@ -498,6 +500,12 @@ export default function TestDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraLinkPanel entityType="test" entityId={testId!} />
|
||||
|
||||
{/* Time Tracking */}
|
||||
<WorklogTimeline entityType="test" entityId={testId!} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -202,3 +202,36 @@ export interface DefensiveTechnique {
|
||||
tactic: string | null;
|
||||
d3fend_url: string | null;
|
||||
}
|
||||
|
||||
// ── Jira ──────────────────────────────────────────────────────────
|
||||
|
||||
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
|
||||
|
||||
export interface JiraLink {
|
||||
id: string;
|
||||
entity_type: JiraLinkEntityType;
|
||||
entity_id: string;
|
||||
jira_issue_key: string;
|
||||
jira_status: string | null;
|
||||
jira_priority: string | null;
|
||||
jira_assignee: string | null;
|
||||
last_synced_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Worklogs ──────────────────────────────────────────────────────
|
||||
|
||||
export interface Worklog {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
user_id: string;
|
||||
activity_type: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration_seconds: number;
|
||||
description: string | null;
|
||||
tempo_synced: string | null;
|
||||
integrity_hash: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user