refactor(ui): unify shared primitives across pages
This commit is contained in:
parent
8536dedc9e
commit
62b381b844
20 changed files with 483 additions and 258 deletions
87
docs/ui-ux-audit-2026-04-20.md
Normal file
87
docs/ui-ux-audit-2026-04-20.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Audit UI/UX transverse - 2026-04-20
|
||||
|
||||
## Périmètre audité
|
||||
|
||||
Front React complet sous `src/components`:
|
||||
- `agents/*`
|
||||
- `projects/*`
|
||||
- `tickets/*`
|
||||
- `trackers/*`
|
||||
- `settings/*`
|
||||
- `layout/*`
|
||||
|
||||
## Incohérences détectées (avant refactor)
|
||||
|
||||
1. Boutons d'action dupliqués avec variations de couleurs et de tailles selon les pages (`bg-blue-600`, `bg-gray-900`, `bg-red-100`, etc.).
|
||||
2. Champs de formulaire répétés avec plusieurs variantes de classes (`input`, `select`, `textarea`) non centralisées.
|
||||
3. Cartes/blocs de contenu dupliqués (`rounded-lg border border-gray-200 bg-white p-4`) avec petites divergences de spacing.
|
||||
4. Alertes (`error`, `warning`, `success`, `info`) construites page par page avec des styles proches mais non unifiés.
|
||||
5. Badges de statut ticket réimplémentés dans plusieurs pages avec logique dupliquée et couverture de statuts non homogène.
|
||||
|
||||
## Composants réutilisés sur plusieurs pages
|
||||
|
||||
### 1) Boutons (multi-pages)
|
||||
|
||||
Usage sur:
|
||||
- Agents: `AgentList`, `AgentForm`
|
||||
- Projets: `ProjectForm`, `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`
|
||||
- Tickets: `TicketList`, `TicketDetail`
|
||||
- Trackers: `TrackerConfig`, `TrackerList`, `FilterBuilder`
|
||||
- Settings: `SettingsPage`
|
||||
- Layout: `WindowControls`, `NotificationCenter`
|
||||
- UI: `ConfirmModal`
|
||||
|
||||
Unification appliquée:
|
||||
- Ajout de `buttonClass()` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
|
||||
- Variantes centralisées: `primary`, `secondary`, `danger`, `dangerSoft`, `neutralDark`, `success`, `ghost`
|
||||
- Tailles centralisées: `xs`, `sm`, `md`, `icon`
|
||||
|
||||
### 2) Formulaires (input/select/textarea + labels)
|
||||
|
||||
Usage sur:
|
||||
- `AgentForm`, `ProjectForm`, `SettingsPage`, `TrackerConfig`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectTasks`, `TicketDetail`, `FilterBuilder`
|
||||
|
||||
Unification appliquée:
|
||||
- `labelClass`, `inputClass`, `textAreaClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
|
||||
- Remplacement des classes inline répétées par ces styles partagés.
|
||||
|
||||
### 3) Cartes de contenu
|
||||
|
||||
Usage sur:
|
||||
- `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `TrackerConfig`, `TrackerList`, `TicketList`, `TicketDetail`, `AgentList`
|
||||
|
||||
Unification appliquée:
|
||||
- `cardClass` / `cardContentClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
|
||||
- Réemploi des mêmes classes de surface sur les pages.
|
||||
|
||||
### 4) Alertes (erreur/succès/warning/info)
|
||||
|
||||
Usage sur:
|
||||
- `AgentForm`, `AgentList`, `ProjectForm`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `SettingsPage`, `TrackerConfig`, `TicketDetail`
|
||||
|
||||
Unification appliquée:
|
||||
- `noticeClass(tone, compact)` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
|
||||
- Tons standards: `error`, `success`, `warning`, `info`.
|
||||
|
||||
### 5) Badges de statut ticket
|
||||
|
||||
Usage sur:
|
||||
- `TicketList`, `TicketDetail`, `ProjectDashboard`
|
||||
|
||||
Unification appliquée:
|
||||
- Nouveau composant [`src/components/ui/TicketStatusBadge.tsx`](/home/leclere/Projets/IA/orchai/src/components/ui/TicketStatusBadge.tsx)
|
||||
- Mapping de statuts mutualisé via `ticketStatusClass()`
|
||||
- Suppression des mappings locaux dupliqués dans les pages tickets/dashboard.
|
||||
|
||||
## Méthode de génération commune
|
||||
|
||||
Oui, pour les composants réutilisés multi-pages identifiés, la génération du style passe désormais par une couche commune:
|
||||
- `primitives.ts` pour les patterns UI transverses
|
||||
- `TicketStatusBadge.tsx` pour les badges de statuts ticket
|
||||
|
||||
Cela réduit la divergence visuelle et simplifie les évolutions de design futures.
|
||||
|
||||
## Limites / reste à harmoniser
|
||||
|
||||
1. Certaines zones très contextuelles (ex: bulles de conversation live, badges de session archivée, blocs de diff) conservent des styles spécifiques, volontairement.
|
||||
2. Une seconde passe peut encore factoriser quelques micro-patterns (ex: tabs, badges non-ticket, liens d'action inline) si nécessaire.
|
||||
|
|
@ -3,6 +3,14 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||
import { createAgent, getAgent, improveAgentPrompt, updateAgent } from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { AgentRole, AgentTool } from "../../lib/types";
|
||||
import {
|
||||
buttonClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
noticeClass,
|
||||
pageTitleClass,
|
||||
textAreaClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function AgentForm() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -76,36 +84,36 @@ export default function AgentForm() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-8">
|
||||
<h2 className="mb-6 text-xl font-bold">{isEditing ? "Edit agent" : "New agent"}</h2>
|
||||
<h2 className={`mb-6 ${pageTitleClass}`}>{isEditing ? "Edit agent" : "New agent"}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
|
||||
{isEditing && isDefaultAgent && (
|
||||
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<div className={noticeClass("info")}>
|
||||
This is a default agent. Its tool and script/prompt can be modified, but its name and
|
||||
role are fixed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isEditing && isDefaultAgent}
|
||||
required
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
|
||||
<label className={labelClass}>Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AgentRole)}
|
||||
disabled={isEditing && isDefaultAgent}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="analyst">Analyst</option>
|
||||
<option value="developer">Developer</option>
|
||||
|
|
@ -113,11 +121,11 @@ export default function AgentForm() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Tool</label>
|
||||
<label className={labelClass}>Tool</label>
|
||||
<select
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as AgentTool)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="claude_code">Claude Code</option>
|
||||
|
|
@ -126,14 +134,14 @@ export default function AgentForm() {
|
|||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className={labelClass}>
|
||||
Script / custom prompt (appended to built-in prompt)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleImprovePrompt()}
|
||||
disabled={loading || initializing || improvingPrompt}
|
||||
className="rounded border border-emerald-300 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={buttonClass({ variant: "success", size: "xs" })}
|
||||
>
|
||||
{improvingPrompt ? "Amélioration..." : "Amélioration"}
|
||||
</button>
|
||||
|
|
@ -143,7 +151,7 @@ export default function AgentForm() {
|
|||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
disabled={loading || initializing || improvingPrompt}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
|
||||
className={`${textAreaClass} disabled:bg-gray-50`}
|
||||
placeholder="Extra instructions for this agent..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
|
|
@ -152,7 +160,7 @@ export default function AgentForm() {
|
|||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -161,14 +169,14 @@ export default function AgentForm() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={loading || initializing || improvingPrompt}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import { deleteAgent, listAgents } from "../../lib/api";
|
|||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
import {
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
noticeClass,
|
||||
pageClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function AgentList() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
|
|
@ -52,13 +59,10 @@ export default function AgentList() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className={pageClass}>
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-bold">Agents</h2>
|
||||
<Link
|
||||
to="/agents/new"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
<h2 className={pageTitleClass}>Agents</h2>
|
||||
<Link to="/agents/new" className={buttonClass({ variant: "primary" })}>
|
||||
New agent
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -66,7 +70,7 @@ export default function AgentList() {
|
|||
{loading && <div className="text-sm text-gray-400">Loading...</div>}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
|
||||
<div className={`mb-4 ${noticeClass("error", true)}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -79,7 +83,7 @@ export default function AgentList() {
|
|||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100"
|
||||
className={`group relative transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100 ${cardContentClass}`}
|
||||
>
|
||||
<Link
|
||||
to={`/agents/${agent.id}/edit`}
|
||||
|
|
@ -117,7 +121,7 @@ export default function AgentList() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentToDelete(agent)}
|
||||
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
markNotificationRead,
|
||||
} from "../../lib/api";
|
||||
import type { OrchaiNotification } from "../../lib/types";
|
||||
import { buttonClass, cardClass, pillClass } from "../ui/primitives";
|
||||
|
||||
type NewNotificationEvent = {
|
||||
notification: OrchaiNotification;
|
||||
|
|
@ -180,7 +181,7 @@ export default function NotificationCenter() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
className={`${buttonClass({ variant: "secondary", size: "sm" })} relative border border-gray-300`}
|
||||
>
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
|
|
@ -191,13 +192,13 @@ export default function NotificationCenter() {
|
|||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-[360px] rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className={`absolute right-0 z-20 mt-2 w-[360px] shadow-lg ${cardClass}`}>
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Notifications</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className={buttonClass({ variant: "ghost", size: "xs" })}
|
||||
disabled={!projectId || unreadCount === 0}
|
||||
>
|
||||
Mark all read
|
||||
|
|
@ -217,11 +218,7 @@ export default function NotificationCenter() {
|
|||
onClick={() =>
|
||||
setFilter(item.id as "all" | "unread" | "errors" | "fixes")
|
||||
}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
filter === item.id
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
className={`${pillClass(filter === item.id)} text-xs`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { buttonClass } from "../ui/primitives";
|
||||
|
||||
async function minimizeWindow() {
|
||||
try {
|
||||
|
|
@ -31,7 +32,7 @@ export default function WindowControls() {
|
|||
type="button"
|
||||
onClick={() => void minimizeWindow()}
|
||||
title="Minimize"
|
||||
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100"
|
||||
className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
|
||||
>
|
||||
_
|
||||
</button>
|
||||
|
|
@ -39,7 +40,7 @@ export default function WindowControls() {
|
|||
type="button"
|
||||
onClick={() => void toggleMaximizeWindow()}
|
||||
title="Maximize / Restore"
|
||||
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100"
|
||||
className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
|
||||
>
|
||||
[]
|
||||
</button>
|
||||
|
|
@ -47,7 +48,7 @@ export default function WindowControls() {
|
|||
type="button"
|
||||
onClick={() => void closeWindow()}
|
||||
title="Close"
|
||||
className="h-7 w-7 rounded border border-red-300 bg-red-50 text-xs text-red-700 hover:bg-red-100"
|
||||
className={`${buttonClass({ variant: "dangerSoft", size: "icon" })} border border-red-300`}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import type {
|
|||
} from "../../lib/types";
|
||||
import TrackerList from "../trackers/TrackerList";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||
import { buttonClass, cardContentClass, pageClass, pageTitleClass } from "../ui/primitives";
|
||||
|
||||
type ActivityLevel = "info" | "success" | "error";
|
||||
|
||||
|
|
@ -331,19 +333,6 @@ export default function ProjectDashboard() {
|
|||
navigate("/");
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "Done":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "Error":
|
||||
return "bg-red-100 text-red-700";
|
||||
default:
|
||||
return "bg-blue-100 text-blue-700";
|
||||
}
|
||||
}
|
||||
|
||||
function formatLeadTime(seconds: number | null): string {
|
||||
if (seconds === null || Number.isNaN(seconds)) {
|
||||
return "—";
|
||||
|
|
@ -374,26 +363,26 @@ export default function ProjectDashboard() {
|
|||
const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—";
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className={pageClass}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||
<h2 className={pageTitleClass}>{project.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/projects/${project.id}/edit`}
|
||||
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
|
||||
className={buttonClass({ variant: "dangerSoft", size: "sm" })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||
<div className={`${cardContentClass} space-y-3`}>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Path:</span>
|
||||
<span className="ml-2 text-sm font-mono">{project.path}</span>
|
||||
|
|
@ -499,7 +488,7 @@ export default function ProjectDashboard() {
|
|||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Live Activity</h3>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div className={`${cardContentClass} space-y-4`}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="text-sm rounded-full bg-blue-50 text-blue-700 px-3 py-1">
|
||||
Polling en cours: {activePollList.length}
|
||||
|
|
@ -566,7 +555,7 @@ export default function ProjectDashboard() {
|
|||
<Link
|
||||
key={ticket.id}
|
||||
to={`/tickets/${ticket.id}`}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
|
||||
className={`${cardContentClass} flex items-center justify-between gap-4`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -574,11 +563,7 @@ export default function ProjectDashboard() {
|
|||
<span className="text-sm font-medium truncate">{ticket.artifact_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${statusBadgeClass(ticket.status)}`}
|
||||
>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<TicketStatusBadge status={ticket.status} className="shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, getProject, updateProject } from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import {
|
||||
buttonClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
noticeClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function ProjectForm() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -60,13 +67,13 @@ export default function ProjectForm() {
|
|||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
<h2 className={`${pageTitleClass} mb-6`}>
|
||||
{isEditing ? "Edit project" : "New project"}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -74,14 +81,14 @@ export default function ProjectForm() {
|
|||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Source
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
|
|
@ -105,7 +112,7 @@ export default function ProjectForm() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
{mode === "local" ? "Folder path" : "Git URL"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -119,13 +126,13 @@ export default function ProjectForm() {
|
|||
? "/home/user/code/myproject"
|
||||
: "https://github.com/org/repo.git"
|
||||
}
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={`flex-1 ${inputClass}`}
|
||||
/>
|
||||
{mode === "local" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowse}
|
||||
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
|
|
@ -136,7 +143,7 @@ export default function ProjectForm() {
|
|||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Base branch
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -144,12 +151,12 @@ export default function ProjectForm() {
|
|||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -158,14 +165,14 @@ export default function ProjectForm() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ import type {
|
|||
GraylogDetection,
|
||||
GraylogSubject,
|
||||
} from "../../lib/types";
|
||||
import {
|
||||
backLinkClass,
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
inputClass,
|
||||
noticeClass,
|
||||
pageStackClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function ProjectGraylog() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
|
|
@ -146,35 +155,32 @@ export default function ProjectGraylog() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-8">
|
||||
<div className={pageStackClass}>
|
||||
<div>
|
||||
{projectId && (
|
||||
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
|
||||
<Link to={`/projects/${projectId}`} className={backLinkClass}>
|
||||
Back
|
||||
</Link>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">Graylog</h2>
|
||||
<h2 className={pageTitleClass}>Graylog</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<div className={noticeClass("error")}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
<div className={noticeClass("success")}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
className="space-y-3 rounded-lg border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<form onSubmit={handleSave} className={`space-y-3 ${cardContentClass}`}>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Configuration</h3>
|
||||
|
||||
<input
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="https://graylog.example.com"
|
||||
|
|
@ -182,7 +188,7 @@ export default function ProjectGraylog() {
|
|||
/>
|
||||
|
||||
<input
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={apiToken}
|
||||
onChange={(event) => setApiToken(event.target.value)}
|
||||
placeholder={
|
||||
|
|
@ -195,7 +201,7 @@ export default function ProjectGraylog() {
|
|||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={analystAgentId}
|
||||
onChange={(event) => setAnalystAgentId(event.target.value)}
|
||||
required
|
||||
|
|
@ -209,7 +215,7 @@ export default function ProjectGraylog() {
|
|||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={developerAgentId}
|
||||
onChange={(event) => setDeveloperAgentId(event.target.value)}
|
||||
required
|
||||
|
|
@ -225,13 +231,13 @@ export default function ProjectGraylog() {
|
|||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={streamId}
|
||||
onChange={(event) => setStreamId(event.target.value)}
|
||||
placeholder="stream_id (optionnel)"
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm md:col-span-3"
|
||||
className={`${inputClass} md:col-span-3`}
|
||||
value={queryFilter}
|
||||
onChange={(event) => setQueryFilter(event.target.value)}
|
||||
placeholder="Filtre query Graylog"
|
||||
|
|
@ -241,21 +247,21 @@ export default function ProjectGraylog() {
|
|||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<input
|
||||
type="number"
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={pollingIntervalMinutes}
|
||||
onChange={(event) => setPollingIntervalMinutes(Number(event.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={lookbackMinutes}
|
||||
onChange={(event) => setLookbackMinutes(Number(event.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
value={scoreThreshold}
|
||||
onChange={(event) => setScoreThreshold(Number(event.target.value))}
|
||||
min={1}
|
||||
|
|
@ -267,28 +273,28 @@ export default function ProjectGraylog() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{saving ? "Sauvegarde..." : "Sauvegarder"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
Tester la connexion
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleManualPoll()}
|
||||
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black"
|
||||
className={buttonClass({ variant: "neutralDark" })}
|
||||
>
|
||||
Poll manuel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className={cardContentClass}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -311,7 +317,7 @@ export default function ProjectGraylog() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className={cardContentClass}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3>
|
||||
|
||||
{detections.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@ import {
|
|||
} from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { Agent, LiveMessage, LiveSession } from "../../lib/types";
|
||||
import {
|
||||
backLinkClass,
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
inputClass,
|
||||
noticeClass,
|
||||
pageStackClass,
|
||||
pageTitleClass,
|
||||
pillClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
interface LiveEventPayload {
|
||||
project_id: string;
|
||||
|
|
@ -303,34 +313,34 @@ export default function ProjectLiveAgent() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className={pageStackClass}>
|
||||
<div>
|
||||
{projectId && (
|
||||
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
|
||||
<Link to={`/projects/${projectId}`} className={backLinkClass}>
|
||||
Back
|
||||
</Link>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">Live agent</h2>
|
||||
<h2 className={pageTitleClass}>Live agent</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!moduleEnabled && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700">
|
||||
<div className={noticeClass("warning", true)}>
|
||||
Le module est désactivé. La lecture reste possible, mais la création de session et l'envoi de message sont bloqués.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreateSession} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<form onSubmit={handleCreateSession} className={cardContentClass}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
value={selectedAgentId}
|
||||
onChange={(e) => setSelectedAgentId(e.target.value)}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
>
|
||||
{usableAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
|
|
@ -343,12 +353,12 @@ export default function ProjectLiveAgent() {
|
|||
value={sessionTitle}
|
||||
onChange={(e) => setSessionTitle(e.target.value)}
|
||||
placeholder="Titre de session (optionnel)"
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingSession || !selectedAgentId || !moduleEnabled}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{creatingSession ? "Création..." : "Créer la session"}
|
||||
</button>
|
||||
|
|
@ -356,7 +366,7 @@ export default function ProjectLiveAgent() {
|
|||
</form>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[260px,1fr]">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className={`${cardContentClass} p-3`}>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Sessions
|
||||
</div>
|
||||
|
|
@ -371,11 +381,7 @@ export default function ProjectLiveAgent() {
|
|||
type="button"
|
||||
key={session.id}
|
||||
onClick={() => void handleSessionChange(session.id)}
|
||||
className={`w-full rounded px-3 py-2 text-left text-sm ${
|
||||
selectedSessionId === session.id
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
className={`${pillClass(selectedSessionId === session.id)} w-full text-left`}
|
||||
>
|
||||
<div className="truncate">{session.title}</div>
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
|
|
@ -426,7 +432,7 @@ export default function ProjectLiveAgent() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className={cardContentClass}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">Discussion</div>
|
||||
|
|
@ -505,7 +511,7 @@ export default function ProjectLiveAgent() {
|
|||
: "Ton message..."
|
||||
}
|
||||
disabled={!selectedSessionId || sending || selectedSession?.status === "archived"}
|
||||
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={`flex-1 ${inputClass}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -516,7 +522,7 @@ export default function ProjectLiveAgent() {
|
|||
!moduleEnabled ||
|
||||
selectedSession?.status === "archived"
|
||||
}
|
||||
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black disabled:opacity-50"
|
||||
className={buttonClass({ variant: "neutralDark" })}
|
||||
>
|
||||
{sending ? "Envoi..." : "Envoyer"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import { Link, useParams } from "react-router-dom";
|
|||
import { listProjectModules, setProjectModuleEnabled } from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { ProjectModule } from "../../lib/types";
|
||||
import {
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
noticeClass,
|
||||
pageClass,
|
||||
pageTitleClass,
|
||||
backLinkClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function ProjectModules() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
|
|
@ -42,18 +50,18 @@ export default function ProjectModules() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className={pageClass}>
|
||||
<div className="mb-6">
|
||||
{projectId && (
|
||||
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
|
||||
<Link to={`/projects/${projectId}`} className={backLinkClass}>
|
||||
Back
|
||||
</Link>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">Modules du projet</h2>
|
||||
<h2 className={pageTitleClass}>Modules du projet</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
|
||||
<div className={`mb-4 ${noticeClass("error", true)}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -62,7 +70,7 @@ export default function ProjectModules() {
|
|||
{modules.map((mod) => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
className={cardContentClass}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -72,7 +80,7 @@ export default function ProjectModules() {
|
|||
{projectId && mod.module_key === "graylog_polling_auto_resolve" && (
|
||||
<Link
|
||||
to={`/projects/${projectId}/graylog`}
|
||||
className="mt-2 inline-flex rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
|
||||
className={`mt-2 ${buttonClass({ variant: "secondary", size: "xs" })}`}
|
||||
>
|
||||
Configurer
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import {
|
|||
} from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { Agent, AgentTask } from "../../lib/types";
|
||||
import {
|
||||
backLinkClass,
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
inputClass,
|
||||
noticeClass,
|
||||
pageStackClass,
|
||||
pageTitleClass,
|
||||
textAreaClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
interface TaskEventPayload {
|
||||
project_id: string;
|
||||
|
|
@ -152,34 +162,34 @@ export default function ProjectTasks() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-8">
|
||||
<div className={pageStackClass}>
|
||||
<div>
|
||||
{projectId && (
|
||||
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
|
||||
<Link to={`/projects/${projectId}`} className={backLinkClass}>
|
||||
Back
|
||||
</Link>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">Tâches agent</h2>
|
||||
<h2 className={pageTitleClass}>Tâches agent</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!moduleEnabled && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700">
|
||||
<div className={noticeClass("warning", true)}>
|
||||
Le module est désactivé. Les tâches existantes restent visibles, mais la création et la relance sont bloquées.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreateTask} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<form onSubmit={handleCreateTask} className={cardContentClass}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-800">Créer une tâche</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
>
|
||||
{usableAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
|
|
@ -193,13 +203,13 @@ export default function ProjectTasks() {
|
|||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titre de la tâche"
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={inputClass}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !agentId || !title.trim() || !moduleEnabled}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{creating ? "Création..." : "Créer"}
|
||||
</button>
|
||||
|
|
@ -210,13 +220,13 @@ export default function ProjectTasks() {
|
|||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Description détaillée"
|
||||
className="mt-3 w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
className={`mt-3 ${textAreaClass}`}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div key={task.id} className={cardContentClass}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-gray-900">{task.title}</div>
|
||||
|
|
@ -234,13 +244,13 @@ export default function ProjectTasks() {
|
|||
)}
|
||||
|
||||
{task.result && (
|
||||
<div className="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900 whitespace-pre-wrap">
|
||||
<div className={`mt-3 whitespace-pre-wrap ${noticeClass("success")}`}>
|
||||
{task.result}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.error && (
|
||||
<div className="mt-3 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 whitespace-pre-wrap">
|
||||
<div className={`mt-3 whitespace-pre-wrap ${noticeClass("error")}`}>
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -251,7 +261,7 @@ export default function ProjectTasks() {
|
|||
type="button"
|
||||
onClick={() => void handleRetry(task.id)}
|
||||
disabled={workingTaskId === task.id || !moduleEnabled}
|
||||
className="rounded bg-gray-900 px-3 py-1 text-xs text-white hover:bg-black disabled:opacity-50"
|
||||
className={buttonClass({ variant: "neutralDark", size: "xs" })}
|
||||
>
|
||||
Relancer
|
||||
</button>
|
||||
|
|
@ -262,7 +272,7 @@ export default function ProjectTasks() {
|
|||
type="button"
|
||||
onClick={() => void handleCancel(task.id)}
|
||||
disabled={workingTaskId === task.id}
|
||||
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ import {
|
|||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { Project, TuleapCredentialsSafe } from "../../lib/types";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
import {
|
||||
buttonClass,
|
||||
cardClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
noticeClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
function normalizeScope(value: string): string | null {
|
||||
return value === "" ? null : value;
|
||||
|
|
@ -160,17 +168,17 @@ export default function SettingsPage() {
|
|||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">Settings</h2>
|
||||
<h2 className={`${pageTitleClass} mb-6`}>Settings</h2>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className={`${cardClass} p-6`}>
|
||||
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Scope</label>
|
||||
<label className={labelClass}>Scope</label>
|
||||
<select
|
||||
value={selectedProjectId ?? ""}
|
||||
onChange={(e) => setSelectedProjectId(normalizeScope(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Global fallback</option>
|
||||
{projects.map((project) => (
|
||||
|
|
@ -191,54 +199,54 @@ export default function SettingsPage() {
|
|||
) : null}
|
||||
|
||||
{usingGlobalFallback && (
|
||||
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
<div className={`mb-4 ${noticeClass("warning")}`}>
|
||||
Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tuleap URL</label>
|
||||
<label className={labelClass}>Tuleap URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={tuleapUrl}
|
||||
onChange={(e) => setTuleapUrl(e.target.value)}
|
||||
required
|
||||
placeholder="https://tuleap.example.com"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<label className={labelClass}>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<label className={labelClass}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="text-green-700 text-sm bg-green-50 border border-green-200 rounded p-2">
|
||||
<div className={noticeClass("success", true)}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -247,7 +255,7 @@ export default function SettingsPage() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={saving || loadingScope}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
|
|
@ -255,7 +263,7 @@ export default function SettingsPage() {
|
|||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !existing || loadingScope}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
{testing ? "Testing..." : "Test connection"}
|
||||
</button>
|
||||
|
|
@ -264,7 +272,7 @@ export default function SettingsPage() {
|
|||
type="button"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "danger" })}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -14,25 +14,15 @@ import {
|
|||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { ProcessedTicket, Worktree } from "../../lib/types";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "Analyzing":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "Developing":
|
||||
return "bg-purple-100 text-purple-700";
|
||||
case "Done":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "Error":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "Cancelled":
|
||||
return "bg-gray-100 text-gray-500";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
}
|
||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||
import {
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
inputClass,
|
||||
noticeClass,
|
||||
pageClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
function DiffViewer({ diff }: { diff: string }) {
|
||||
if (!diff) {
|
||||
|
|
@ -228,22 +218,19 @@ export default function TicketDetail() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className={pageClass}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<button onClick={() => navigate(-1)} className="mb-1 text-sm text-blue-600 hover:underline">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className={buttonClass({ variant: "ghost", size: "xs" })}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h2 className="flex items-center gap-3 text-xl font-bold">
|
||||
<h2 className={`flex items-center gap-3 ${pageTitleClass}`}>
|
||||
<span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span>
|
||||
{ticket.artifact_title}
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
|
||||
ticket.status
|
||||
)}`}
|
||||
>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -251,7 +238,7 @@ export default function TicketDetail() {
|
|||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
|
@ -262,7 +249,7 @@ export default function TicketDetail() {
|
|||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "dangerSoft", size: "sm" })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -271,7 +258,7 @@ export default function TicketDetail() {
|
|||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||
<div className={`mb-4 ${noticeClass("error")}`}>{error}</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex gap-1 border-b border-gray-200">
|
||||
|
|
@ -295,7 +282,7 @@ export default function TicketDetail() {
|
|||
|
||||
{tab === "info" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className={`space-y-3 ${cardContentClass}`}>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
<span className="ml-2 text-sm">{ticket.status}</span>
|
||||
|
|
@ -340,7 +327,7 @@ export default function TicketDetail() {
|
|||
</div>
|
||||
|
||||
{worktree && worktree.status === "Active" && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className={cardContentClass}>
|
||||
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">Target branch</p>
|
||||
|
|
@ -350,7 +337,7 @@ export default function TicketDetail() {
|
|||
onClick={() =>
|
||||
setBranchInputMode((mode) => (mode === "select" ? "manual" : "select"))
|
||||
}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className={buttonClass({ variant: "ghost", size: "xs" })}
|
||||
>
|
||||
{branchInputMode === "select"
|
||||
? "Use manual input"
|
||||
|
|
@ -364,7 +351,7 @@ export default function TicketDetail() {
|
|||
<select
|
||||
value={targetBranch}
|
||||
onChange={(e) => setTargetBranch(e.target.value)}
|
||||
className="flex-1 rounded border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||
className={`flex-1 bg-white ${inputClass}`}
|
||||
>
|
||||
{availableBranches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
|
|
@ -378,13 +365,13 @@ export default function TicketDetail() {
|
|||
placeholder="Target branch (e.g. feature/login)"
|
||||
value={targetBranch}
|
||||
onChange={(e) => setTargetBranch(e.target.value)}
|
||||
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||
className={`flex-1 ${inputClass}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleApplyFix}
|
||||
disabled={loading || branchesLoading || !targetBranch.trim()}
|
||||
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "success", size: "sm" })}
|
||||
>
|
||||
Apply fix
|
||||
</button>
|
||||
|
|
@ -406,7 +393,7 @@ export default function TicketDetail() {
|
|||
<button
|
||||
onClick={() => setIsDeleteWorktreeModalOpen(true)}
|
||||
disabled={loading}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
className={buttonClass({ variant: "dangerGhost", size: "xs" })}
|
||||
>
|
||||
Delete worktree
|
||||
</button>
|
||||
|
|
@ -414,7 +401,7 @@ export default function TicketDetail() {
|
|||
)}
|
||||
|
||||
{worktree && worktree.status === "Merged" && (
|
||||
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<div className={noticeClass("info")}>
|
||||
Fix applied to branch: {worktree.merged_into}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,13 @@ import { useEffect, useState } from "react";
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import { getProject, listProcessedTickets } from "../../lib/api";
|
||||
import type { ProcessedTicket, Project } from "../../lib/types";
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "Analyzing":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "Developing":
|
||||
return "bg-purple-100 text-purple-700";
|
||||
case "Done":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "Error":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "Cancelled":
|
||||
return "bg-gray-100 text-gray-500";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
}
|
||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||
import {
|
||||
cardContentClass,
|
||||
pageClass,
|
||||
pageTitleClass,
|
||||
pillClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function TicketList() {
|
||||
const { projectId } = useParams();
|
||||
|
|
@ -41,13 +29,13 @@ export default function TicketList() {
|
|||
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className={pageClass}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<Link to={`/projects/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
{project?.name}
|
||||
</Link>
|
||||
<h2 className="text-xl font-bold">Processed Tickets</h2>
|
||||
<h2 className={pageTitleClass}>Processed Tickets</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -56,11 +44,7 @@ export default function TicketList() {
|
|||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`rounded px-3 py-1 text-sm ${
|
||||
filter === s
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
className={pillClass(filter === s)}
|
||||
>
|
||||
{s === "all" ? "All" : s}
|
||||
{s !== "all" && (
|
||||
|
|
@ -80,7 +64,7 @@ export default function TicketList() {
|
|||
<Link
|
||||
key={ticket.id}
|
||||
to={`/tickets/${ticket.id}`}
|
||||
className="block rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
|
||||
className={`block transition-colors hover:border-blue-300 ${cardContentClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -100,13 +84,7 @@ export default function TicketList() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
|
||||
ticket.status
|
||||
)}`}
|
||||
>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<TicketStatusBadge status={ticket.status} className="shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FilterGroup, Filter, TrackerField } from "../../lib/types";
|
||||
import { buttonClass, cardContentClass, inputClass } from "../ui/primitives";
|
||||
|
||||
const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const;
|
||||
|
||||
|
|
@ -60,13 +61,13 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
<div className="flex-1 border-t border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className={cardContentClass}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">Group {gi + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeGroup(gi)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
className={buttonClass({ variant: "dangerGhost", size: "xs" })}
|
||||
>
|
||||
Remove group
|
||||
</button>
|
||||
|
|
@ -96,7 +97,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
value: [],
|
||||
})
|
||||
}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1"
|
||||
className={`flex-1 ${inputClass}`}
|
||||
>
|
||||
<option value="">Select field...</option>
|
||||
{availableFields.map((f) => (
|
||||
|
|
@ -112,7 +113,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
onChange={(e) =>
|
||||
updateCondition(gi, ci, { ...cond, operator: e.target.value })
|
||||
}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
|
|
@ -125,7 +126,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(gi, ci)}
|
||||
className="text-xs text-red-400 hover:text-red-600 whitespace-nowrap"
|
||||
className={`${buttonClass({ variant: "dangerGhost", size: "xs" })} whitespace-nowrap`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
|
@ -162,7 +163,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => addCondition(gi)}
|
||||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||||
className={`${buttonClass({ variant: "ghost", size: "xs" })} mt-3`}
|
||||
>
|
||||
+ Add OR condition
|
||||
</button>
|
||||
|
|
@ -173,7 +174,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
|
|||
<button
|
||||
type="button"
|
||||
onClick={addGroup}
|
||||
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
|
||||
className={`${buttonClass({ variant: "secondary", fullWidth: true })} border-2 border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600`}
|
||||
>
|
||||
+ Add filter group (AND)
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ import {
|
|||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { FilterGroup, TrackerField, Agent } from "../../lib/types";
|
||||
import FilterBuilder from "./FilterBuilder";
|
||||
import {
|
||||
buttonClass,
|
||||
cardContentClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
noticeClass,
|
||||
pageTitleClass,
|
||||
} from "../ui/primitives";
|
||||
|
||||
export default function TrackerConfig() {
|
||||
const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>();
|
||||
|
|
@ -165,7 +173,7 @@ export default function TrackerConfig() {
|
|||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
<h2 className={`${pageTitleClass} mb-6`}>
|
||||
{isEditing ? "Edit tracker" : "Add tracker"}
|
||||
</h2>
|
||||
|
||||
|
|
@ -175,21 +183,21 @@ export default function TrackerConfig() {
|
|||
)}
|
||||
|
||||
{analystAgents.length === 0 || developerAgents.length === 0 ? (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
<div className={noticeClass("warning")}>
|
||||
You need at least one analyst agent and one developer agent before creating a tracker.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing && trackerStatus === "invalid" && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<div className={noticeClass("error")}>
|
||||
This tracker is invalid. Select valid agents and save to reactivate it.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div className={`${cardContentClass} space-y-4`}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Tracker ID
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -203,14 +211,14 @@ export default function TrackerConfig() {
|
|||
}}
|
||||
required
|
||||
min={1}
|
||||
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={`w-40 ${inputClass}`}
|
||||
placeholder="e.g. 42"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadFields}
|
||||
disabled={!trackerId || fieldsLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{fieldsLoading ? "Loading..." : "Load tracker fields"}
|
||||
</button>
|
||||
|
|
@ -218,7 +226,7 @@ export default function TrackerConfig() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -227,12 +235,12 @@ export default function TrackerConfig() {
|
|||
onChange={(e) => setTrackerLabel(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Bugs"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Polling interval (minutes)
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -241,7 +249,7 @@ export default function TrackerConfig() {
|
|||
onChange={(e) => setPollingInterval(Number(e.target.value))}
|
||||
required
|
||||
min={1}
|
||||
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={`w-40 ${inputClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -270,17 +278,17 @@ export default function TrackerConfig() {
|
|||
)}
|
||||
|
||||
{/* Agent config */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div className={`${cardContentClass} space-y-4`}>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Analyst agent
|
||||
</label>
|
||||
<select
|
||||
value={analystAgentId}
|
||||
onChange={(e) => setAnalystAgentId(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select an analyst agent</option>
|
||||
{analystAgents.map((agent) => (
|
||||
|
|
@ -291,14 +299,14 @@ export default function TrackerConfig() {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className={labelClass}>
|
||||
Developer agent
|
||||
</label>
|
||||
<select
|
||||
value={developerAgentId}
|
||||
onChange={(e) => setDeveloperAgentId(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select a developer agent</option>
|
||||
{developerAgents.map((agent) => (
|
||||
|
|
@ -311,7 +319,7 @@ export default function TrackerConfig() {
|
|||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
<div className={noticeClass("error", true)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -320,14 +328,14 @@ export default function TrackerConfig() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={loading || initializing}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
{loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/${projectId}`)}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||
import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
|
||||
import type { WatchedTracker } from "../../lib/types";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
import { buttonClass, cardContentClass } from "../ui/primitives";
|
||||
|
||||
interface Props {
|
||||
trackers: WatchedTracker[];
|
||||
|
|
@ -68,7 +69,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
|||
{trackers.map((tracker) => (
|
||||
<div
|
||||
key={tracker.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
|
||||
className={`flex items-center justify-between gap-4 ${cardContentClass}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -98,13 +99,13 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
|||
type="button"
|
||||
onClick={() => handlePollNow(tracker)}
|
||||
disabled={pollingIds.includes(tracker.id) || tracker.status !== "valid"}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "primary", size: "xs" })}
|
||||
>
|
||||
{pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
|
||||
</button>
|
||||
<Link
|
||||
to={`/projects/${projectId}/trackers/${tracker.id}/edit`}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary", size: "xs" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
|
|
@ -112,14 +113,14 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
|||
type="button"
|
||||
onClick={() => handleToggleEnabled(tracker)}
|
||||
disabled={tracker.status !== "valid"}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary", size: "xs" })}
|
||||
>
|
||||
{tracker.enabled ? "Pause" : "Resume"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrackerToRemove(tracker)}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200"
|
||||
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
|
@ -129,7 +130,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
|||
|
||||
<Link
|
||||
to={`/projects/${projectId}/trackers/new`}
|
||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
Add tracker
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { buttonClass, cardClass } from "./primitives";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -45,7 +46,7 @@ export default function ConfirmModal({
|
|||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl"
|
||||
className={`w-full max-w-md p-5 shadow-xl ${cardClass}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
|
|
@ -55,7 +56,7 @@ export default function ConfirmModal({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded bg-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-300"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
|
@ -63,7 +64,7 @@ export default function ConfirmModal({
|
|||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={confirmDisabled}
|
||||
className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
|
||||
className={buttonClass({ variant: "danger", size: "sm" })}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
|
|
|||
33
src/components/ui/TicketStatusBadge.tsx
Normal file
33
src/components/ui/TicketStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cx } from "./primitives";
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
Pending: "bg-yellow-100 text-yellow-700",
|
||||
Analyzing: "bg-blue-100 text-blue-700",
|
||||
Developing: "bg-purple-100 text-purple-700",
|
||||
Done: "bg-green-100 text-green-700",
|
||||
Error: "bg-red-100 text-red-700",
|
||||
Cancelled: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
export function ticketStatusClass(status: string): string {
|
||||
return statusClasses[status] ?? "bg-gray-100 text-gray-700";
|
||||
}
|
||||
|
||||
interface TicketStatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TicketStatusBadge({ status, className }: TicketStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
ticketStatusClass(status),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
89
src/components/ui/primitives.ts
Normal file
89
src/components/ui/primitives.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export function cx(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export type ButtonVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "danger"
|
||||
| "dangerSoft"
|
||||
| "dangerGhost"
|
||||
| "neutralDark"
|
||||
| "success"
|
||||
| "ghost";
|
||||
|
||||
export type ButtonSize = "xs" | "sm" | "md" | "icon";
|
||||
|
||||
interface ButtonClassOptions {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const buttonVariantClasses: Record<ButtonVariant, string> = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-200 text-gray-700 hover:bg-gray-300",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
dangerSoft: "bg-red-100 text-red-700 hover:bg-red-200",
|
||||
dangerGhost: "bg-transparent text-red-600 hover:underline",
|
||||
neutralDark: "bg-gray-900 text-white hover:bg-black",
|
||||
success: "bg-green-600 text-white hover:bg-green-700",
|
||||
ghost: "bg-transparent text-blue-600 hover:underline",
|
||||
};
|
||||
|
||||
const buttonSizeClasses: Record<ButtonSize, string> = {
|
||||
xs: "px-3 py-1 text-xs",
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-sm",
|
||||
icon: "h-7 w-7 px-0 text-xs",
|
||||
};
|
||||
|
||||
export function buttonClass({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
fullWidth = false,
|
||||
}: ButtonClassOptions = {}): string {
|
||||
return cx(
|
||||
"inline-flex items-center justify-center rounded font-medium transition-colors disabled:opacity-50",
|
||||
buttonVariantClasses[variant],
|
||||
buttonSizeClasses[size],
|
||||
fullWidth && "w-full"
|
||||
);
|
||||
}
|
||||
|
||||
export const pageClass = "p-8";
|
||||
export const pageStackClass = "space-y-6 p-8";
|
||||
export const pageTitleClass = "text-xl font-bold";
|
||||
export const backLinkClass = "mb-1 inline-flex text-sm text-blue-600 hover:underline";
|
||||
|
||||
export const cardClass = "rounded-lg border border-gray-200 bg-white";
|
||||
export const cardContentClass = `${cardClass} p-4`;
|
||||
|
||||
export const labelClass = "mb-1 block text-sm font-medium text-gray-700";
|
||||
export const inputClass =
|
||||
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
export const textAreaClass =
|
||||
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
|
||||
export type NoticeTone = "error" | "success" | "warning" | "info";
|
||||
|
||||
const noticeToneClasses: Record<NoticeTone, string> = {
|
||||
error: "border-red-200 bg-red-50 text-red-700",
|
||||
success: "border-green-200 bg-green-50 text-green-700",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
info: "border-blue-200 bg-blue-50 text-blue-700",
|
||||
};
|
||||
|
||||
export function noticeClass(tone: NoticeTone, compact = false): string {
|
||||
return cx(
|
||||
"rounded border text-sm",
|
||||
compact ? "p-2" : "p-3",
|
||||
noticeToneClasses[tone]
|
||||
);
|
||||
}
|
||||
|
||||
export function pillClass(active: boolean): string {
|
||||
return active
|
||||
? "rounded px-3 py-1 text-sm bg-gray-900 text-white"
|
||||
: "rounded px-3 py-1 text-sm bg-gray-100 text-gray-700 hover:bg-gray-200";
|
||||
}
|
||||
Loading…
Reference in a new issue