fix: added ability to change the name for any client

This is done inside the details view for any client; found via the logs by clicking on a client or throught the 'clients' page
This commit is contained in:
pommee
2025-12-28 11:16:46 +01:00
parent 0805e07db5
commit 2bca60597a
4 changed files with 177 additions and 8 deletions
+26
View File
@@ -14,6 +14,7 @@ func (api *API) registerClientRoutes() {
api.routes.GET("/client/:ip/details", api.getClientDetails) api.routes.GET("/client/:ip/details", api.getClientDetails)
api.routes.GET("/client/:ip/history", api.getClientHistory) api.routes.GET("/client/:ip/history", api.getClientHistory)
api.routes.PUT("/client/:ip/name/:name", api.updateClientName)
api.routes.PUT("/client/:ip/bypass/:bypass", api.updateClientBypass) api.routes.PUT("/client/:ip/bypass/:bypass", api.updateClientBypass)
} }
@@ -83,6 +84,31 @@ func (api *API) getTopClients(c *gin.Context) {
c.JSON(http.StatusOK, topClients) c.JSON(http.StatusOK, topClients)
} }
func (api *API) updateClientName(c *gin.Context) {
ip := c.Param("ip")
name := c.Param("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "'name' can't be empty"})
return
}
err := api.RequestService.UpdateClientName(ip, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Refresh DNS server client caches to reflect the updated client name
err = api.DNS.PopulateClientCaches()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh DNS server client caches"})
return
}
c.Status(http.StatusOK)
}
func (api *API) updateClientBypass(c *gin.Context) { func (api *API) updateClientBypass(c *gin.Context) {
ip := c.Param("ip") ip := c.Param("ip")
bypass := c.Param("bypass") bypass := c.Param("bypass")
+13
View File
@@ -30,6 +30,7 @@ type Repository interface {
GetTopClients() ([]map[string]interface{}, error) GetTopClients() ([]map[string]interface{}, error)
CountQueries(search string) (int, error) CountQueries(search string) (int, error)
UpdateClientName(ip string, name string) error
UpdateClientBypass(ip string, bypass bool) error UpdateClientBypass(ip string, bypass bool) error
DeleteRequestLogsTimebased(vacuum vacuumFunc, requestThreshold, maxRetries int, retryDelay time.Duration) error DeleteRequestLogsTimebased(vacuum vacuumFunc, requestThreshold, maxRetries int, retryDelay time.Duration) error
@@ -488,6 +489,18 @@ func (r *repository) CountQueries(search string) (int, error) {
return int(total), err return int(total), err
} }
func (r *repository) UpdateClientName(ip, name string) error {
err := r.db.Model(&database.RequestLog{}).
Where("client_ip = ?", ip).
Update("client_name", name).Error
if err != nil {
return fmt.Errorf("failed whiled updating client name: %w", err)
}
return nil
}
func (r *repository) UpdateClientBypass(ip string, bypass bool) error { func (r *repository) UpdateClientBypass(ip string, bypass bool) error {
err := r.db.Model(&database.MacAddress{}). err := r.db.Model(&database.MacAddress{}).
Where("ip = ?", ip). Where("ip = ?", ip).
+9
View File
@@ -79,6 +79,15 @@ func (s *Service) CountQueries(search string) (int, error) {
return s.repository.CountQueries(search) return s.repository.CountQueries(search)
} }
func (s *Service) UpdateClientName(ip string, name string) error {
if err := s.repository.UpdateClientName(ip, name); err != nil {
return err
}
log.Info("Name changed to %s for client %s", name, ip)
return nil
}
func (s *Service) UpdateClientBypass(ip string, bypass bool) error { func (s *Service) UpdateClientBypass(ip string, bypass bool) error {
if err := s.repository.UpdateClientBypass(ip, bypass); err != nil { if err := s.repository.UpdateClientBypass(ip, bypass); err != nil {
return err return err
+129 -8
View File
@@ -6,20 +6,26 @@ import { cn } from "@/lib/utils";
import { ClientEntry } from "@/pages/clients"; import { ClientEntry } from "@/pages/clients";
import { GetRequest, PutRequest } from "@/util"; import { GetRequest, PutRequest } from "@/util";
import { import {
ArrowsClockwiseIcon,
CaretDownIcon, CaretDownIcon,
CheckIcon,
ClockCounterClockwiseIcon, ClockCounterClockwiseIcon,
EyeglassesIcon, EyeglassesIcon,
LightningIcon, LightningIcon,
PencilIcon,
PlusMinusIcon, PlusMinusIcon,
RowsIcon, RowsIcon,
ShieldIcon, ShieldIcon,
SparkleIcon, SparkleIcon,
TargetIcon TargetIcon,
XIcon
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import TimeAgo from "react-timeago"; import TimeAgo from "react-timeago";
import { toast } from "sonner"; import { toast } from "sonner";
import { SettingRow } from "../settings/SettingsRow"; import { SettingRow } from "../settings/SettingsRow";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type AllDomains = { type AllDomains = {
[domain: string]: number; [domain: string]: number;
@@ -53,6 +59,10 @@ export function CardDetails({
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [clientHistory, setClientHistory] = useState(null); const [clientHistory, setClientHistory] = useState(null);
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(clientEntry.name || "");
const [updatingName, setUpdatingName] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchClient() { async function fetchClient() {
setIsLoading(true); setIsLoading(true);
@@ -140,16 +150,127 @@ export function CardDetails({
} }
} }
const updateClientName = async () => {
const newName = editedName.trim();
if (newName === "") {
toast.warning("Client name cannot be empty");
setIsEditingName(false);
setEditedName(clientDetails?.clientInfo.name || clientEntry.name || "");
return;
}
if (newName === (clientDetails?.clientInfo.name || clientEntry.name)) {
toast.info("Name was not changed");
setIsEditingName(false);
return;
}
setUpdatingName(true);
try {
const [code, response] = await PutRequest(
`client/${clientEntry.ip}/name/${encodeURIComponent(newName)}`,
null,
false
);
if (code === 200) {
toast.success(`Client name updated to "${newName}"`);
setClientDetails((prev) =>
prev
? {
...prev,
clientInfo: {
...prev.clientInfo,
name: newName
}
}
: prev
);
setEditedName(newName);
setIsEditingName(false);
} else {
toast.error(response?.error || "Failed to update client name");
setEditedName(clientDetails?.clientInfo.name || clientEntry.name || "");
}
} catch {
toast.error("Error updating client name");
setEditedName(clientDetails?.clientInfo.name || clientEntry.name || "");
} finally {
setUpdatingName(false);
setIsEditingName(false);
}
};
const cancelNameEdit = () => {
setEditedName(clientDetails?.clientInfo.name || clientEntry.name || "");
setIsEditingName(false);
};
return ( return (
<Dialog open onOpenChange={onClose}> <Dialog open onOpenChange={onClose}>
<DialogContent className="border-none bg-accent rounded-lg w-full max-w-6xl max-h-3/4 overflow-y-auto"> <DialogContent className="border-none bg-accent rounded-lg w-full max-w-6xl max-h-3/4 overflow-y-auto">
<DialogTitle> <DialogTitle>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-bold mb-1"> {isEditingName ? (
{clientEntry.name || "unknown"} <div className="flex items-center gap-2">
</h2> <Input
<div className="flex items-center text-sm gap-2"> type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
autoFocus
disabled={updatingName}
maxLength={255}
/>
<Button
size="icon"
variant={"ghost"}
onClick={updateClientName}
disabled={updatingName}
>
{updatingName ? (
<ArrowsClockwiseIcon className="animate-spin" />
) : (
<CheckIcon className="text-green-500" />
)}
</Button>
<Button
size="icon"
variant="ghost"
onClick={cancelNameEdit}
disabled={updatingName}
className="h-9 w-9 hover:bg-red-950/20"
>
<XIcon className="text-red-500" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 group">
<h2 className="text-xl sm:text-2xl font-bold mb-1 truncate">
{clientDetails?.clientInfo.name ||
clientEntry.name ||
"unknown"}
</h2>
<Button
size="icon"
variant="ghost"
onClick={() => {
setIsEditingName(true);
setEditedName(
clientDetails?.clientInfo.name || clientEntry.name || ""
);
}}
>
<PencilIcon size={16} className="text-muted-foreground" />
</Button>
</div>
)}
<div className="flex items-center text-sm gap-2 flex-wrap">
<span className="bg-muted-foreground/20 px-2 py-0.5 rounded-md font-mono text-xs"> <span className="bg-muted-foreground/20 px-2 py-0.5 rounded-md font-mono text-xs">
ip: {clientEntry.ip} ip: {clientEntry.ip}
</span> </span>
@@ -165,7 +286,7 @@ export function CardDetails({
)} )}
</div> </div>
</div> </div>
<div className="text-right hidden sm:block"> <div className="text-right hidden sm:block font-mono">
<span className="text-xs">Last Activity</span> <span className="text-xs">Last Activity</span>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{clientEntry.lastSeen} {clientEntry.lastSeen}