feat: frontend types, API wrappers, and Settings page for Tuleap credentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b20ecc5493
commit
43a3483fd0
5 changed files with 296 additions and 1 deletions
|
|
@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import AppLayout from "./components/layout/AppLayout";
|
import AppLayout from "./components/layout/AppLayout";
|
||||||
import ProjectForm from "./components/projects/ProjectForm";
|
import ProjectForm from "./components/projects/ProjectForm";
|
||||||
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
||||||
|
import SettingsPage from "./components/settings/SettingsPage";
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,6 +21,7 @@ function App() {
|
||||||
<Route path="/projects/new" element={<ProjectForm />} />
|
<Route path="/projects/new" element={<ProjectForm />} />
|
||||||
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
||||||
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ export default function Sidebar() {
|
||||||
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
|
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-gray-700">
|
||||||
|
<Link to="/settings" className="block px-3 py-2 rounded text-sm text-gray-300 hover:bg-gray-800 hover:text-white">
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
src/components/settings/SettingsPage.tsx
Normal file
174
src/components/settings/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
getTuleapCredentials,
|
||||||
|
setTuleapCredentials,
|
||||||
|
deleteTuleapCredentials,
|
||||||
|
testTuleapConnection,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type { TuleapCredentialsSafe } from "../../lib/types";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [tuleapUrl, setTuleapUrl] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [existing, setExisting] = useState<TuleapCredentialsSafe | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTuleapCredentials().then((creds) => {
|
||||||
|
if (creds) {
|
||||||
|
setExisting(creds);
|
||||||
|
setTuleapUrl(creds.tuleap_url);
|
||||||
|
setUsername(creds.username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMessages();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const creds = await setTuleapCredentials(tuleapUrl, username, password);
|
||||||
|
setExisting(creds);
|
||||||
|
setPassword("");
|
||||||
|
setSuccess("Credentials saved.");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
clearMessages();
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const msg = await testTuleapConnection();
|
||||||
|
setSuccess(msg);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!window.confirm("Delete Tuleap credentials? This cannot be undone.")) return;
|
||||||
|
clearMessages();
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteTuleapCredentials();
|
||||||
|
setExisting(null);
|
||||||
|
setTuleapUrl("");
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setSuccess("Credentials deleted.");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto p-8">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Settings</h2>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={existing ? "Leave empty to keep current" : ""}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="text-green-700 text-sm bg-green-50 border border-green-200 rounded p-2">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing || !existing}
|
||||||
|
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? "Testing..." : "Test connection"}
|
||||||
|
</button>
|
||||||
|
{existing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project } from "./types";
|
import type {
|
||||||
|
Project,
|
||||||
|
TuleapCredentialsSafe,
|
||||||
|
AgentConfig,
|
||||||
|
FilterGroup,
|
||||||
|
WatchedTracker,
|
||||||
|
TrackerField,
|
||||||
|
ProcessedTicket,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -32,3 +40,45 @@ export async function updateProject(
|
||||||
export async function deleteProject(id: string): Promise<void> {
|
export async function deleteProject(id: string): Promise<void> {
|
||||||
return invoke("delete_project", { id });
|
return invoke("delete_project", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
|
||||||
|
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
|
||||||
|
}
|
||||||
|
export async function getTuleapCredentials(): Promise<TuleapCredentialsSafe | null> {
|
||||||
|
return invoke("get_tuleap_credentials");
|
||||||
|
}
|
||||||
|
export async function deleteTuleapCredentials(): Promise<void> {
|
||||||
|
return invoke("delete_tuleap_credentials");
|
||||||
|
}
|
||||||
|
export async function testTuleapConnection(): Promise<string> {
|
||||||
|
return invoke("test_tuleap_connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trackers
|
||||||
|
export async function addTracker(projectId: string, trackerId: number, trackerLabel: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[]): Promise<WatchedTracker> {
|
||||||
|
return invoke("add_tracker", { projectId, trackerId, trackerLabel, pollingInterval, agentConfig, filters });
|
||||||
|
}
|
||||||
|
export async function listTrackers(projectId: string): Promise<WatchedTracker[]> {
|
||||||
|
return invoke("list_trackers", { projectId });
|
||||||
|
}
|
||||||
|
export async function updateTracker(id: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[], enabled: boolean): Promise<void> {
|
||||||
|
return invoke("update_tracker", { id, pollingInterval, agentConfig, filters, enabled });
|
||||||
|
}
|
||||||
|
export async function removeTracker(id: string): Promise<void> {
|
||||||
|
return invoke("remove_tracker", { id });
|
||||||
|
}
|
||||||
|
export async function getTrackerFields(trackerId: number): Promise<TrackerField[]> {
|
||||||
|
return invoke("get_tracker_fields", { trackerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tickets & Polling
|
||||||
|
export async function listProcessedTickets(projectId: string): Promise<ProcessedTicket[]> {
|
||||||
|
return invoke("list_processed_tickets", { projectId });
|
||||||
|
}
|
||||||
|
export async function manualPoll(trackerId: string): Promise<ProcessedTicket[]> {
|
||||||
|
return invoke("manual_poll", { trackerId });
|
||||||
|
}
|
||||||
|
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
|
||||||
|
return invoke("get_queue_status", { projectId });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,66 @@ export interface Project {
|
||||||
base_branch: string;
|
base_branch: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TuleapCredentialsSafe {
|
||||||
|
id: string;
|
||||||
|
tuleap_url: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
analyst_command: string;
|
||||||
|
analyst_args: string[];
|
||||||
|
developer_command: string;
|
||||||
|
developer_args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterGroup {
|
||||||
|
conditions: Filter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackerField {
|
||||||
|
field_id: number;
|
||||||
|
label: string;
|
||||||
|
field_type: string;
|
||||||
|
values: FieldValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldValue {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedTracker {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
tracker_id: number;
|
||||||
|
tracker_label: string;
|
||||||
|
polling_interval: number;
|
||||||
|
agent_config: AgentConfig;
|
||||||
|
filters: FilterGroup[];
|
||||||
|
enabled: boolean;
|
||||||
|
last_polled_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedTicket {
|
||||||
|
id: string;
|
||||||
|
tracker_id: string;
|
||||||
|
artifact_id: number;
|
||||||
|
artifact_title: string;
|
||||||
|
artifact_data: string;
|
||||||
|
status: string;
|
||||||
|
analyst_report: string | null;
|
||||||
|
developer_report: string | null;
|
||||||
|
worktree_path: string | null;
|
||||||
|
branch_name: string | null;
|
||||||
|
detected_at: string;
|
||||||
|
processed_at: string | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue