mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 22:13:21 +00:00
Merge pull request #122 from PatchMon/feat/delete_repos
feat: add repository deletion functionality
This commit is contained in:
@@ -289,6 +289,77 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delete a specific repository (admin only)
|
||||||
|
router.delete(
|
||||||
|
"/:repositoryId",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { repositoryId } = req.params;
|
||||||
|
|
||||||
|
// Check if repository exists first
|
||||||
|
const existingRepository = await prisma.repositories.findUnique({
|
||||||
|
where: { id: repositoryId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
host_repositories: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingRepository) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Repository not found",
|
||||||
|
details: "The repository may have been deleted or does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete repository and all related data (cascade will handle host_repositories)
|
||||||
|
await prisma.repositories.delete({
|
||||||
|
where: { id: repositoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Repository deleted successfully",
|
||||||
|
deletedRepository: {
|
||||||
|
id: existingRepository.id,
|
||||||
|
name: existingRepository.name,
|
||||||
|
url: existingRepository.url,
|
||||||
|
hostCount: existingRepository._count.host_repositories,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Repository deletion error:", error);
|
||||||
|
|
||||||
|
// Handle specific Prisma errors
|
||||||
|
if (error.code === "P2025") {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Repository not found",
|
||||||
|
details: "The repository may have been deleted or does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "P2003") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Cannot delete repository due to foreign key constraints",
|
||||||
|
details: "The repository has related data that prevents deletion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to delete repository",
|
||||||
|
details: error.message || "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup orphaned repositories (admin only)
|
// Cleanup orphaned repositories (admin only)
|
||||||
router.delete(
|
router.delete(
|
||||||
"/cleanup/orphaned",
|
"/cleanup/orphaned",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Columns,
|
Columns,
|
||||||
Database,
|
Database,
|
||||||
Eye,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Lock,
|
Lock,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -15,20 +14,24 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { repositoryAPI } from "../utils/api";
|
import { repositoryAPI } from "../utils/api";
|
||||||
|
|
||||||
const Repositories = () => {
|
const Repositories = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
||||||
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
||||||
const [sortField, setSortField] = useState("name");
|
const [sortField, setSortField] = useState("name");
|
||||||
const [sortDirection, setSortDirection] = useState("asc");
|
const [sortDirection, setSortDirection] = useState("asc");
|
||||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||||
|
const [deleteModalData, setDeleteModalData] = useState(null);
|
||||||
|
|
||||||
// Column configuration
|
// Column configuration
|
||||||
const [columnConfig, setColumnConfig] = useState(() => {
|
const [columnConfig, setColumnConfig] = useState(() => {
|
||||||
@@ -79,6 +82,15 @@ const Repositories = () => {
|
|||||||
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete repository mutation
|
||||||
|
const deleteRepositoryMutation = useMutation({
|
||||||
|
mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["repositories"]);
|
||||||
|
queryClient.invalidateQueries(["repository-stats"]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Get visible columns in order
|
// Get visible columns in order
|
||||||
const visibleColumns = columnConfig
|
const visibleColumns = columnConfig
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
@@ -137,6 +149,32 @@ const Repositories = () => {
|
|||||||
updateColumnConfig(defaultConfig);
|
updateColumnConfig(defaultConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteRepository = (repo, e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setDeleteModalData({
|
||||||
|
id: repo.id,
|
||||||
|
name: repo.name,
|
||||||
|
hostCount: repo.hostCount || 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (repo) => {
|
||||||
|
navigate(`/repositories/${repo.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteModalData) {
|
||||||
|
deleteRepositoryMutation.mutate(deleteModalData.id);
|
||||||
|
setDeleteModalData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
setDeleteModalData(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Filter and sort repositories
|
// Filter and sort repositories
|
||||||
const filteredAndSortedRepositories = useMemo(() => {
|
const filteredAndSortedRepositories = useMemo(() => {
|
||||||
if (!repositories) return [];
|
if (!repositories) return [];
|
||||||
@@ -224,6 +262,56 @@ const Repositories = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteModalData && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Delete Repository
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<strong>"{deleteModalData.name}"</strong>?
|
||||||
|
</p>
|
||||||
|
{deleteModalData.hostCount > 0 && (
|
||||||
|
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||||
|
⚠️ This repository is currently assigned to{" "}
|
||||||
|
{deleteModalData.hostCount} host
|
||||||
|
{deleteModalData.hostCount !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelDelete}
|
||||||
|
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteRepositoryMutation.isPending
|
||||||
|
? "Deleting..."
|
||||||
|
: "Delete Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -414,7 +502,8 @@ const Repositories = () => {
|
|||||||
{filteredAndSortedRepositories.map((repo) => (
|
{filteredAndSortedRepositories.map((repo) => (
|
||||||
<tr
|
<tr
|
||||||
key={repo.id}
|
key={repo.id}
|
||||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(repo)}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<td
|
<td
|
||||||
@@ -518,13 +607,17 @@ const Repositories = () => {
|
|||||||
);
|
);
|
||||||
case "actions":
|
case "actions":
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="flex items-center justify-center">
|
||||||
to={`/repositories/${repo.id}`}
|
<button
|
||||||
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
|
type="button"
|
||||||
|
onClick={(e) => handleDeleteRepository(repo, e)}
|
||||||
|
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
title="Delete repository"
|
||||||
>
|
>
|
||||||
View
|
<Trash2 className="h-4 w-4" />
|
||||||
<Eye className="h-3 w-3" />
|
</button>
|
||||||
</Link>
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
|
Trash2,
|
||||||
Unlock,
|
Unlock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -24,13 +25,14 @@ const RepositoryDetail = () => {
|
|||||||
const priorityId = useId();
|
const priorityId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
const { repositoryId } = useParams();
|
const { repositoryId } = useParams();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [formData, setFormData] = useState({});
|
const [formData, setFormData] = useState({});
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
// Fetch repository details
|
// Fetch repository details
|
||||||
const {
|
const {
|
||||||
@@ -96,6 +98,15 @@ const RepositoryDetail = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete repository mutation
|
||||||
|
const deleteRepositoryMutation = useMutation({
|
||||||
|
mutationFn: () => repositoryAPI.delete(repositoryId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["repositories"]);
|
||||||
|
navigate("/repositories");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: repository.name,
|
name: repository.name,
|
||||||
@@ -115,6 +126,19 @@ const RepositoryDetail = () => {
|
|||||||
setFormData({});
|
setFormData({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
deleteRepositoryMutation.mutate();
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -174,6 +198,56 @@ const RepositoryDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Delete Repository
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<strong>"{repository?.name}"</strong>?
|
||||||
|
</p>
|
||||||
|
{repository?.host_repositories?.length > 0 && (
|
||||||
|
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||||
|
⚠️ This repository is currently assigned to{" "}
|
||||||
|
{repository.host_repositories.length} host
|
||||||
|
{repository.host_repositories.length !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelDelete}
|
||||||
|
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteRepositoryMutation.isPending
|
||||||
|
? "Deleting..."
|
||||||
|
: "Delete Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -229,9 +303,24 @@ const RepositoryDetail = () => {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={handleEdit} className="btn-primary">
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="btn-outline border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:border-red-700 flex items-center gap-2"
|
||||||
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleteRepositoryMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
Edit Repository
|
Edit Repository
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export const repositoryAPI = {
|
|||||||
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
||||||
update: (repositoryId, data) =>
|
update: (repositoryId, data) =>
|
||||||
api.put(`/repositories/${repositoryId}`, data),
|
api.put(`/repositories/${repositoryId}`, data),
|
||||||
|
delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`),
|
||||||
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
||||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
|||||||
Reference in New Issue
Block a user