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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user