Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type AssignedItemCount {
enum AssignmentItemType {
TICKET
KNOWLEDGE_ARTICLE
INSIGHT
}

enum AssignmentTargetType {
Expand Down Expand Up @@ -1894,6 +1895,17 @@ type Query {
a list of insights must not be able to pull notes row by row.
"""
insightNotes(insightId: ID!, first: Int, after: String): InsightNoteConnection!
"""
A ready-to-send chat message about one insight: a short ask plus the `@insight:<id>` marker the
assistant expands into the finding itself. Built server-side so the marker carries the stored id,
which the client never sees, and the text cannot go stale when the detector refreshes the insight.
"""
insightChatPrompt(id: ID!): String!
"""
Which status an insight may move to from each status it can be in. The mutations below enforce
exactly this table, so a client can drive its controls from it rather than restating the rules.
"""
insightStatusTransitions: [InsightStatusTransition!]!

""" Relay Node interface queries"""
node(id: ID!): Node
Expand Down Expand Up @@ -3467,6 +3479,13 @@ input SnoozeInsightInput {
until: Instant!
}

type InsightStatusTransition {
"The status an insight is in."
from: InsightStatus!
"Every status it may move to from there."
to: [InsightStatus!]!
}

type InsightConnection {
edges: [InsightEdge!]!
pageInfo: PageInfo!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import { AssignedItemsView, useAssignedItems } from '@/components/assignments';

/**
* "Assigned Ticket": the tickets filed from this incident. The server records
* the link as an assignment owned by the insight (item INSIGHT → target TICKET)
* when a ticket is created with `insightId`, so this is the ticket page's
* Assigned Items view pointed at the incident. Nothing while loading or empty —
* a section that appears with its rows, not a slot the page reserves.
*/
export function IncidentAssignedTickets({ incidentId }: { incidentId: string }) {
const { tickets } = useAssignedItems({ itemId: incidentId, itemType: 'INSIGHT' });
if (!tickets?.length) return null;
return (
<section className="flex flex-col gap-[var(--spacing-system-m)]">
<h2 className="text-ods-text-primary text-h2">Assigned Ticket</h2>
<AssignedItemsView itemId={incidentId} itemType="INSIGHT" showTitle={false} />
</section>
);
}
57 changes: 33 additions & 24 deletions src/app/(app)/incidents/components/incident-details-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import { InsightStatus } from '@/generated/schema-enums';
import { routes } from '@/lib/routes';
import { CONTEXT_ENTITY_KIND } from '../../mingo/context/context-types';
import { useTrackOpenView } from '../../mingo/context/use-track-open-view';
import { useMingoLauncherStore } from '../../mingo/stores/mingo-launcher-store';
import { useIncident } from '../hooks/use-incident';
import { mingoActionFor, useFixWithMingo } from '../hooks/use-fix-with-mingo';
import { useIncident, useIncidentDetail } from '../hooks/use-incident';
import { useLatestIncidentDialog } from '../hooks/use-incident-dialogs';
import { useIncidentTransitions } from '../hooks/use-incident-transitions';
import { incidentMingoDraft } from '../utils/fix-with-mingo-prompt';
import { INCIDENT_TRANSITION_ACTIONS, transitionsFrom } from '../utils/incident-labels';
import { INCIDENT_TRANSITION_ACTIONS } from '../utils/incident-labels';
import { transitionsFrom } from '../utils/incident-transform';
import { IncidentAssignedTickets } from './incident-assigned-tickets';
import { IncidentAssignee } from './incident-assignee';
import { IncidentNotes } from './incident-notes';
import { IncidentQueryResults } from './incident-query-results';
import { IncidentQueryResults, IncidentQueryResultsSkeleton } from './incident-query-results';
import { IncidentSummaryCard, IncidentSummaryCardSkeleton } from './incident-summary-card';
import { SnoozeIncidentModal } from './snooze-incident-modal';
import { transitionMenuItems } from './transition-menu-items';
Expand All @@ -41,36 +43,39 @@ const TICKET_PREFILL_DESCRIPTION_MAX = 1000;
* what waits for it.
*/
function IncidentHeader({ incidentId }: IncidentDetailsViewProps) {
const incident = useIncident(incidentId);
const { incident, transitions } = useIncidentDetail(incidentId);

const handleBack = useSafeBack(routes.incidents.list);
const canOpenMingo = useMingoLauncherStore(state => state.canOpen);
const mingoControls = useFixWithMingo();
// One Mingo button: reopen the chat already started from this incident, or start one.
const mingoSession = useLatestIncidentDialog(incident.insightId);
// Mingo's "open view": this incident rides on every message sent while the page is up.
useTrackOpenView({ type: CONTEXT_ENTITY_KIND.INSIGHT, id: incident.id, label: incident.title });
useTrackOpenView({ type: CONTEXT_ENTITY_KIND.INSIGHT, id: incident.insightId, label: incident.title });
const { transition, snoozeTarget, cancelSnooze, confirmSnooze, isMutating, isSnoozing } = useIncidentTransitions();

// The status button is named after the transition the technician most likely
// wants — Resolve while the incident is open — and its menu lists every legal
// one (design: "Resolve ▾"). A resolved incident offers Archive / Reopen.
const targets = transitionsFrom(incident.status);
const targets = transitionsFrom(transitions, incident.status);
const primary = targets.includes(InsightStatus.RESOLVED) ? InsightStatus.RESOLVED : targets[0];
// A deleted assignee is not offered by the ticket picker — the ticket starts unassigned.
const ticketAssignee = incident.assignee && !incident.assignee.deleted ? incident.assignee : undefined;

const mingoIcon = (
<MingoIcon
className="size-5"
eyesColor="var(--ods-flamingo-cyan-base)"
cornerColor="var(--ods-flamingo-cyan-base)"
/>
);
const mingoAction: PageActionButton = {
...mingoActionFor(incident, mingoSession, mingoControls),
variant: 'outline',
icon: mingoIcon,
};

const actions: PageActionButton[] = [
{
label: 'Fix with Mingo',
variant: 'outline',
icon: (
<MingoIcon
className="size-5"
eyesColor="var(--ods-flamingo-cyan-base)"
cornerColor="var(--ods-flamingo-cyan-base)"
/>
),
onClick: () => useMingoLauncherStore.getState().draftToMingo(incidentMingoDraft(incident)),
disabled: !canOpenMingo,
},
mingoAction,
{
label: 'Create Ticket',
variant: 'outline',
Expand All @@ -84,6 +89,8 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) {
deviceName: incident.deviceName,
assigneeId: ticketAssignee?.id,
assigneeName: ticketAssignee?.name,
insightId: incident.insightId,
insightTitle: incident.title,
}),
},
...(primary
Expand All @@ -92,7 +99,7 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) {
label: INCIDENT_TRANSITION_ACTIONS[primary].label,
variant: 'outline' as const,
disabled: isMutating,
submenu: transitionMenuItems(incident, transition, isMutating),
submenu: transitionMenuItems(incident, transitions, transition, isMutating),
},
]
: []),
Expand Down Expand Up @@ -168,6 +175,7 @@ export function IncidentDetailsSkeleton() {
<>
<IncidentSummaryCardSkeleton />
<NotesSectionSkeleton />
<IncidentQueryResultsSkeleton />
</>
}
/>
Expand Down Expand Up @@ -195,7 +203,8 @@ export const IncidentDetailsView = memo(function IncidentDetailsViewImpl({ incid
<IncidentSummary incidentId={incidentId} />
</Suspense>
<IncidentNotes incidentId={incidentId} />
<Suspense fallback={null}>
<IncidentAssignedTickets incidentId={incidentId} />
<Suspense fallback={<IncidentQueryResultsSkeleton />}>
<IncidentEvidence incidentId={incidentId} />
</Suspense>
</>
Expand Down
27 changes: 25 additions & 2 deletions src/app/(app)/incidents/components/incident-query-results.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { QueryReportTable } from '@flamingo-stack/openframe-frontend-core/components/ui';
import type { ReactNode } from 'react';
import type { Incident } from '../utils/incident-transform';

/** The first column of the evidence table: the machine every row came from. */
Expand All @@ -17,8 +18,7 @@ export function IncidentQueryResults({ incident }: { incident: Incident }) {
const rows = (incident.queryResult ?? []).map(row => ({ ...row, [DEVICE_COLUMN]: incident.deviceName }));

return (
<section className="flex flex-col gap-[var(--spacing-system-m)]">
<h2 className="text-ods-text-primary text-h2">Query Details</h2>
<QueryResultsFrame>
<QueryReportTable
data={rows}
columnOrder={[DEVICE_COLUMN]}
Expand All @@ -29,6 +29,29 @@ export function IncidentQueryResults({ incident }: { incident: Incident }) {
: 'Evidence was not kept for this incident.'
}
/>
</QueryResultsFrame>
);
}

/** The section's shell — the heading over the table — shared by the loaded state and its skeleton. */
function QueryResultsFrame({ children }: { children: ReactNode }) {
return (
<section className="flex flex-col gap-[var(--spacing-system-m)]">
<h2 className="text-ods-text-primary text-h2">Query Details</h2>
{children}
</section>
);
}

/**
* The same heading over the report table's own loading rows. The column count
* is the detecting query's and unknown until the record lands; four is the
* common shape (device + three query columns), one row the common count.
*/
export function IncidentQueryResultsSkeleton() {
return (
<QueryResultsFrame>
<QueryReportTable data={[]} loading skeletonRows={1} skeletonColumns={4} showExport={false} />
</QueryResultsFrame>
);
}
Loading
Loading