fix(ui): make all Jira and time panels read-only everywhere
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
WorklogTimeline: add readOnly prop — hides 'Log Time' button and form. TestPhaseTimeline: remove 'Sync to Tempo' button from TempoSyncBadge; only displays the green 'Tempo' badge when already synced. Cleans up unused imports (useState, useMutation, useQueryClient, syncTestToTempo). CampaignDetailPage: JiraLinkPanel and WorklogTimeline both now rendered with readOnly=true; JiraLinkPanel receives campaign name as label. Jira tickets and time worklogs are created automatically by the system (campaign activation, test workflow) — no manual editing from detail pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* TestPhaseTimeline
|
* TestPhaseTimeline
|
||||||
*
|
*
|
||||||
* Read-only timeline of automated phase durations, with Tempo sync status and
|
* Read-only timeline of automated phase durations with Tempo sync status.
|
||||||
* a manual "Sync to Tempo" button for the Red Team Execution phase.
|
|
||||||
*
|
*
|
||||||
* 1. Red Team Execution (red_started_at → blue_started_at, minus paused)
|
* 1. Red Team Execution (red_started_at → blue_started_at, minus paused)
|
||||||
* 2. Blue Queue (blue_started_at → blue_work_started_at)
|
* 2. Blue Queue (blue_started_at → blue_work_started_at)
|
||||||
@@ -11,14 +10,12 @@
|
|||||||
* 5. Blue Lead Validation (blue_validated_at + status)
|
* 5. Blue Lead Validation (blue_validated_at + status)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Clock, Sword, Shield, CheckCircle, XCircle, Timer, RefreshCw, Loader2,
|
Clock, Sword, Shield, CheckCircle, XCircle, Timer,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Test } from "../types/models";
|
import type { Test } from "../types/models";
|
||||||
import { listTestWorklogs, type Worklog } from "../api/worklogs";
|
import { listTestWorklogs, type Worklog } from "../api/worklogs";
|
||||||
import { syncTestToTempo } from "../api/tests";
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -118,41 +115,11 @@ function ValidationBadge({ status }: { status: string | null }) {
|
|||||||
|
|
||||||
function TempoSyncBadge({
|
function TempoSyncBadge({
|
||||||
worklog,
|
worklog,
|
||||||
testId,
|
|
||||||
onSynced,
|
|
||||||
}: {
|
}: {
|
||||||
worklog: Worklog | undefined;
|
worklog: Worklog | undefined;
|
||||||
testId: string;
|
|
||||||
onSynced: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
if (!worklog?.tempo_synced) return null;
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () => syncTestToTempo(testId),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
const r = data.results[0];
|
|
||||||
if (r?.status === "synced") {
|
|
||||||
setToast("Synced to Tempo ✓");
|
|
||||||
onSynced();
|
|
||||||
} else if (r?.status === "already_synced") {
|
|
||||||
setToast("Already synced");
|
|
||||||
} else {
|
|
||||||
setToast(r?.detail ?? "Skipped — check Tempo settings");
|
|
||||||
}
|
|
||||||
setTimeout(() => setToast(null), 4000);
|
|
||||||
},
|
|
||||||
onError: (e: unknown) => {
|
|
||||||
const msg =
|
|
||||||
e && typeof e === "object" && "response" in e
|
|
||||||
? ((e as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Sync failed")
|
|
||||||
: "Sync failed";
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(null), 5000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Already synced
|
|
||||||
if (worklog?.tempo_synced) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-green-900/30 text-green-400"
|
className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-green-900/30 text-green-400"
|
||||||
@@ -161,29 +128,6 @@ function TempoSyncBadge({
|
|||||||
<CheckCircle className="h-3 w-3" /> Tempo
|
<CheckCircle className="h-3 w-3" /> Tempo
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Not synced — show button
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<button
|
|
||||||
onClick={() => mutation.mutate()}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs border border-blue-500/30 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-50 transition-colors"
|
|
||||||
title="Push this phase's time to Tempo"
|
|
||||||
>
|
|
||||||
{mutation.isPending ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Sync to Tempo
|
|
||||||
</button>
|
|
||||||
{toast && (
|
|
||||||
<span className="text-xs text-gray-400">{toast}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────
|
||||||
@@ -194,8 +138,6 @@ interface TestPhaseTimelineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelineProps) {
|
export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelineProps) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
red_started_at,
|
red_started_at,
|
||||||
blue_started_at,
|
blue_started_at,
|
||||||
@@ -217,10 +159,6 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
|
|
||||||
const redWorklog = worklogs.find((w) => w.activity_type === "red_team_execution");
|
const redWorklog = worklogs.find((w) => w.activity_type === "red_team_execution");
|
||||||
|
|
||||||
const refreshWorklogs = () => {
|
|
||||||
if (testId) queryClient.invalidateQueries({ queryKey: ["worklogs", "test", testId] });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute per-phase durations
|
// Compute per-phase durations
|
||||||
const redExecSecs =
|
const redExecSecs =
|
||||||
red_started_at && blue_started_at
|
red_started_at && blue_started_at
|
||||||
@@ -270,12 +208,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
duration={redExecSecs}
|
duration={redExecSecs}
|
||||||
isLast={++phaseIdx === lastIdx}
|
isLast={++phaseIdx === lastIdx}
|
||||||
extra={
|
extra={
|
||||||
testId ? (
|
redWorklog ? (
|
||||||
<TempoSyncBadge
|
<TempoSyncBadge worklog={redWorklog} />
|
||||||
worklog={redWorklog}
|
|
||||||
testId={testId}
|
|
||||||
onSynced={refreshWorklogs}
|
|
||||||
/>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
interface WorklogTimelineProps {
|
interface WorklogTimelineProps {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
/** When true, hides the Log Time button and form (read-only display). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
|
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
|
||||||
@@ -36,7 +38,7 @@ function formatDate(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorklogTimeline({ entityType, entityId }: WorklogTimelineProps) {
|
export default function WorklogTimeline({ entityType, entityId, readOnly = false }: WorklogTimelineProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -92,6 +94,7 @@ export default function WorklogTimeline({ entityType, entityId }: WorklogTimelin
|
|||||||
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
|
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
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"
|
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"
|
||||||
@@ -106,11 +109,12 @@ export default function WorklogTimeline({ entityType, entityId }: WorklogTimelin
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New worklog form */}
|
{/* New worklog form — only in edit mode */}
|
||||||
{showForm && (
|
{!readOnly && showForm && (
|
||||||
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
|
<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 className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -629,10 +629,10 @@ export default function CampaignDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Jira & Worklogs */}
|
{/* Jira & Worklogs — read-only, automatically managed */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<JiraLinkPanel entityType="campaign" entityId={campaignId!} />
|
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
|
||||||
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
|
<WorklogTimeline entityType="campaign" entityId={campaignId!} readOnly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Test to Campaign Modal */}
|
{/* Add Test to Campaign Modal */}
|
||||||
|
|||||||
Reference in New Issue
Block a user