feat: migrate UI to @cameleer/design-system, add backend endpoints
Backend: - Add agent_events table (V5) and lifecycle event recording - Add route catalog endpoint (GET /routes/catalog) - Add route metrics endpoint (GET /routes/metrics) - Add agent events endpoint (GET /agents/events-log) - Enrich AgentInstanceResponse with tps, errorRate, activeRoutes, uptimeSeconds - Add TimescaleDB retention/compression policies (V6) Frontend: - Replace custom Mission Control UI with @cameleer/design-system components - Rebuild all pages: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth, AgentInstance, RBAC, AuditLog, OIDC, DatabaseAdmin, OpenSearchAdmin, Swagger - New LayoutShell with design system AppShell, Sidebar, TopBar, CommandPalette - Consume design system from Gitea npm registry (@cameleer/design-system@0.0.1) - Add .npmrc for scoped registry, update Dockerfile with REGISTRY_TOKEN arg CI: - Pass REGISTRY_TOKEN build-arg to UI Docker build step Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
-f ui/Dockerfile \
|
-f ui/Dockerfile \
|
||||||
|
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||||
$TAGS \
|
$TAGS \
|
||||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
||||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package com.cameleer3.server.app.agent;
|
package com.cameleer3.server.app.agent;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Periodic task that checks agent lifecycle and expires old commands.
|
* Periodic task that checks agent lifecycle and expires old commands.
|
||||||
* <p>
|
* <p>
|
||||||
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
||||||
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
||||||
* expired pending commands.
|
* expired pending commands. Records lifecycle events for state transitions.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class AgentLifecycleMonitor {
|
public class AgentLifecycleMonitor {
|
||||||
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
||||||
|
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
public AgentLifecycleMonitor(AgentRegistryService registryService) {
|
public AgentLifecycleMonitor(AgentRegistryService registryService,
|
||||||
|
AgentEventService agentEventService) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||||
public void checkLifecycle() {
|
public void checkLifecycle() {
|
||||||
try {
|
try {
|
||||||
|
// Snapshot states before lifecycle check
|
||||||
|
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
statesBefore.put(agent.id(), agent.state());
|
||||||
|
}
|
||||||
|
|
||||||
registryService.checkLifecycle();
|
registryService.checkLifecycle();
|
||||||
registryService.expireOldCommands();
|
registryService.expireOldCommands();
|
||||||
|
|
||||||
|
// Detect transitions and record events
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
AgentState before = statesBefore.get(agent.id());
|
||||||
|
if (before != null && before != agent.state()) {
|
||||||
|
String eventType = mapTransitionEvent(before, agent.state());
|
||||||
|
if (eventType != null) {
|
||||||
|
agentEventService.recordEvent(agent.id(), agent.group(), eventType,
|
||||||
|
agent.name() + " " + before + " -> " + agent.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error during agent lifecycle check", e);
|
log.error("Error during agent lifecycle check", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String mapTransitionEvent(AgentState from, AgentState to) {
|
||||||
|
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the {@link AgentRegistryService} bean.
|
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
||||||
* <p>
|
* <p>
|
||||||
* Follows the established pattern: core module plain class, app module bean config.
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
*/
|
*/
|
||||||
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
|
|||||||
config.getCommandExpiryMs()
|
config.getCommandExpiryMs()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||||
|
return new AgentEventService(repository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ public class OpenApiConfig {
|
|||||||
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
||||||
"StatsTimeseries", "TimeseriesBucket",
|
"StatsTimeseries", "TimeseriesBucket",
|
||||||
"SearchResultExecutionSummary", "UserInfo",
|
"SearchResultExecutionSummary", "UserInfo",
|
||||||
"ProcessorNode"
|
"ProcessorNode",
|
||||||
|
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||||
|
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
|
||||||
);
|
);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentEventResponse;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agents/events-log")
|
||||||
|
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
||||||
|
public class AgentEventsController {
|
||||||
|
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
|
public AgentEventsController(AgentEventService agentEventService) {
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Query agent events",
|
||||||
|
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||||
|
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||||
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) String agentId,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
|
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
||||||
|
.stream()
|
||||||
|
.map(AgentEventResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
|||||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
@@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -31,8 +33,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||||
@@ -50,17 +57,23 @@ public class AgentRegistrationController {
|
|||||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final Ed25519SigningService ed25519SigningService;
|
private final Ed25519SigningService ed25519SigningService;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
public AgentRegistrationController(AgentRegistryService registryService,
|
public AgentRegistrationController(AgentRegistryService registryService,
|
||||||
AgentRegistryConfig config,
|
AgentRegistryConfig config,
|
||||||
BootstrapTokenValidator bootstrapTokenValidator,
|
BootstrapTokenValidator bootstrapTokenValidator,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
Ed25519SigningService ed25519SigningService) {
|
Ed25519SigningService ed25519SigningService,
|
||||||
|
AgentEventService agentEventService,
|
||||||
|
JdbcTemplate jdbc) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.ed25519SigningService = ed25519SigningService;
|
this.ed25519SigningService = ed25519SigningService;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -97,6 +110,9 @@ public class AgentRegistrationController {
|
|||||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
||||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
||||||
|
|
||||||
|
agentEventService.recordEvent(request.agentId(), group, "REGISTERED",
|
||||||
|
"Agent registered: " + request.name());
|
||||||
|
|
||||||
// Issue JWT tokens with AGENT role
|
// Issue JWT tokens with AGENT role
|
||||||
List<String> roles = List.of("AGENT");
|
List<String> roles = List.of("AGENT");
|
||||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
||||||
@@ -171,7 +187,7 @@ public class AgentRegistrationController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all agents",
|
@Operation(summary = "List all agents",
|
||||||
description = "Returns all registered agents, optionally filtered by status and/or group")
|
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or group")
|
||||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
@@ -198,9 +214,52 @@ public class AgentRegistrationController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AgentInstanceResponse> response = agents.stream()
|
// Enrich with runtime metrics from continuous aggregates
|
||||||
.map(AgentInstanceResponse::from)
|
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||||
|
final List<AgentInfo> finalAgents = agents;
|
||||||
|
|
||||||
|
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||||
|
.map(a -> {
|
||||||
|
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||||
|
double[] m = agentMetrics.get(a.group());
|
||||||
|
if (m != null) {
|
||||||
|
long groupAgentCount = finalAgents.stream()
|
||||||
|
.filter(ag -> ag.group().equals(a.group())).count();
|
||||||
|
double agentTps = groupAgentCount > 0 ? m[0] / groupAgentCount : 0;
|
||||||
|
double errorRate = m[1];
|
||||||
|
int activeRoutes = (int) m[2];
|
||||||
|
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, double[]> queryAgentMetrics() {
|
||||||
|
Map<String, double[]> result = new HashMap<>();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT group_name, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY group_name",
|
||||||
|
rs -> {
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double tps = total / 60.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
int activeRoutes = rs.getInt("active_routes");
|
||||||
|
result.put(rs.getString("group_name"), new double[]{tps, errorRate, activeRoutes});
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentSummary;
|
||||||
|
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
||||||
|
import com.cameleer3.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
||||||
|
public class RouteCatalogController {
|
||||||
|
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/catalog")
|
||||||
|
@Operation(summary = "Get route catalog",
|
||||||
|
description = "Returns all applications with their routes, agents, and health status")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||||
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
|
||||||
|
// Group agents by application (group name)
|
||||||
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
|
.collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
// Collect all distinct routes per app
|
||||||
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
Set<String> routes = new LinkedHashSet<>();
|
||||||
|
for (AgentInfo agent : entry.getValue()) {
|
||||||
|
if (agent.routeIds() != null) {
|
||||||
|
routes.addAll(agent.routeIds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routesByApp.put(entry.getKey(), routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query route-level stats for the last 24 hours
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from24h = now.minus(24, ChronoUnit.HOURS);
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
|
||||||
|
// Route exchange counts from continuous aggregate
|
||||||
|
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT group_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY group_name, route_id",
|
||||||
|
rs -> {
|
||||||
|
String key = rs.getString("group_name") + "/" + rs.getString("route_id");
|
||||||
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
|
},
|
||||||
|
Timestamp.from(from24h), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-agent TPS from the last minute
|
||||||
|
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT group_name, SUM(total_count) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY group_name",
|
||||||
|
rs -> {
|
||||||
|
// This gives per-app TPS; we'll distribute among agents below
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build catalog entries
|
||||||
|
List<AppCatalogEntry> catalog = new ArrayList<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
String appId = entry.getKey();
|
||||||
|
List<AgentInfo> agents = entry.getValue();
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||||
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
|
.map(routeId -> {
|
||||||
|
String key = appId + "/" + routeId;
|
||||||
|
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||||
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
|
return new RouteSummary(routeId, count, lastSeen);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Agent summaries
|
||||||
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
|
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Health = worst state among agents
|
||||||
|
String health = computeWorstHealth(agents);
|
||||||
|
|
||||||
|
// Total exchange count for the app
|
||||||
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
|
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
||||||
|
agents.size(), health, totalExchanges));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||||
|
boolean hasDead = false;
|
||||||
|
boolean hasStale = false;
|
||||||
|
for (AgentInfo a : agents) {
|
||||||
|
if (a.state() == AgentState.DEAD) hasDead = true;
|
||||||
|
if (a.state() == AgentState.STALE) hasStale = true;
|
||||||
|
}
|
||||||
|
if (hasDead) return "dead";
|
||||||
|
if (hasStale) return "stale";
|
||||||
|
return "live";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Metrics", description = "Route performance metrics")
|
||||||
|
public class RouteMetricsController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteMetricsController(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/metrics")
|
||||||
|
@Operation(summary = "Get route metrics",
|
||||||
|
description = "Returns aggregated performance metrics per route for the given time window")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
|
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String appId) {
|
||||||
|
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||||
|
|
||||||
|
var sql = new StringBuilder(
|
||||||
|
"SELECT group_name, route_id, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||||
|
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
params.add(Timestamp.from(fromInstant));
|
||||||
|
params.add(Timestamp.from(toInstant));
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND group_name = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY group_name, route_id ORDER BY group_name, route_id");
|
||||||
|
|
||||||
|
// Key struct for sparkline lookup
|
||||||
|
record RouteKey(String appId, String routeId) {}
|
||||||
|
List<RouteKey> routeKeys = new ArrayList<>();
|
||||||
|
|
||||||
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
|
String groupName = rs.getString("group_name");
|
||||||
|
String routeId = rs.getString("route_id");
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double avgDur = rs.getDouble("avg_dur");
|
||||||
|
double p99Dur = rs.getDouble("p99_dur");
|
||||||
|
|
||||||
|
double successRate = total > 0 ? (double) (total - failed) / total : 1.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||||
|
|
||||||
|
routeKeys.add(new RouteKey(groupName, routeId));
|
||||||
|
return new RouteMetrics(routeId, groupName, total, successRate,
|
||||||
|
avgDur, p99Dur, errorRate, tps, List.of());
|
||||||
|
}, params.toArray());
|
||||||
|
|
||||||
|
// Fetch sparklines (12 buckets over the time window)
|
||||||
|
if (!metrics.isEmpty()) {
|
||||||
|
int sparkBuckets = 12;
|
||||||
|
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
||||||
|
|
||||||
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
|
RouteMetrics m = metrics.get(i);
|
||||||
|
try {
|
||||||
|
List<Double> sparkline = jdbc.query(
|
||||||
|
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||||
|
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"AND group_name = ? AND route_id = ? " +
|
||||||
|
"GROUP BY period ORDER BY period",
|
||||||
|
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||||
|
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||||
|
m.appId(), m.routeId());
|
||||||
|
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||||
|
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||||
|
m.errorRate(), m.throughputPerSec(), sparkline));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Leave sparkline empty on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Agent lifecycle event")
|
||||||
|
public record AgentEventResponse(
|
||||||
|
@NotNull long id,
|
||||||
|
@NotNull String agentId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull String eventType,
|
||||||
|
String detail,
|
||||||
|
@NotNull Instant timestamp
|
||||||
|
) {
|
||||||
|
public static AgentEventResponse from(AgentEventRecord record) {
|
||||||
|
return new AgentEventResponse(
|
||||||
|
record.id(), record.agentId(), record.appId(),
|
||||||
|
record.eventType(), record.detail(), record.timestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import com.cameleer3.server.core.agent.AgentInfo;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Schema(description = "Agent instance summary")
|
@Schema(description = "Agent instance summary with runtime metrics")
|
||||||
public record AgentInstanceResponse(
|
public record AgentInstanceResponse(
|
||||||
@NotNull String id,
|
@NotNull String id,
|
||||||
@NotNull String name,
|
@NotNull String name,
|
||||||
@@ -15,13 +16,29 @@ public record AgentInstanceResponse(
|
|||||||
@NotNull String status,
|
@NotNull String status,
|
||||||
@NotNull List<String> routeIds,
|
@NotNull List<String> routeIds,
|
||||||
@NotNull Instant registeredAt,
|
@NotNull Instant registeredAt,
|
||||||
@NotNull Instant lastHeartbeat
|
@NotNull Instant lastHeartbeat,
|
||||||
|
double tps,
|
||||||
|
double errorRate,
|
||||||
|
int activeRoutes,
|
||||||
|
int totalRoutes,
|
||||||
|
long uptimeSeconds
|
||||||
) {
|
) {
|
||||||
public static AgentInstanceResponse from(AgentInfo info) {
|
public static AgentInstanceResponse from(AgentInfo info) {
|
||||||
|
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
info.id(), info.name(), info.group(),
|
info.id(), info.name(), info.group(),
|
||||||
info.state().name(), info.routeIds(),
|
info.state().name(), info.routeIds(),
|
||||||
info.registeredAt(), info.lastHeartbeat()
|
info.registeredAt(), info.lastHeartbeat(),
|
||||||
|
0.0, 0.0,
|
||||||
|
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||||
|
uptime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||||
|
return new AgentInstanceResponse(
|
||||||
|
id, name, group, status, routeIds, registeredAt, lastHeartbeat,
|
||||||
|
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of an agent instance for sidebar display")
|
||||||
|
public record AgentSummary(
|
||||||
|
@NotNull String id,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull String status,
|
||||||
|
@NotNull double tps
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Application catalog entry with routes and agents")
|
||||||
|
public record AppCatalogEntry(
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull List<RouteSummary> routes,
|
||||||
|
@NotNull List<AgentSummary> agents,
|
||||||
|
@NotNull int agentCount,
|
||||||
|
@NotNull String health,
|
||||||
|
@NotNull long exchangeCount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Aggregated route performance metrics")
|
||||||
|
public record RouteMetrics(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
@NotNull double successRate,
|
||||||
|
@NotNull double avgDurationMs,
|
||||||
|
@NotNull double p99DurationMs,
|
||||||
|
@NotNull double errorRate,
|
||||||
|
@NotNull double throughputPerSec,
|
||||||
|
@NotNull List<Double> sparkline
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of a route within an application")
|
||||||
|
public record RouteSummary(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
Instant lastSeen
|
||||||
|
) {}
|
||||||
@@ -81,6 +81,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresAgentEventRepository implements AgentEventRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresAgentEventRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(String agentId, String appId, String eventType, String detail) {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO agent_events (agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?)",
|
||||||
|
agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
var sql = new StringBuilder("SELECT id, agent_id, app_id, event_type, detail, timestamp FROM agent_events WHERE 1=1");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND app_id = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
if (agentId != null) {
|
||||||
|
sql.append(" AND agent_id = ?");
|
||||||
|
params.add(agentId);
|
||||||
|
}
|
||||||
|
if (from != null) {
|
||||||
|
sql.append(" AND timestamp >= ?");
|
||||||
|
params.add(Timestamp.from(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
sql.append(" AND timestamp < ?");
|
||||||
|
params.add(Timestamp.from(to));
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
||||||
|
params.add(limit);
|
||||||
|
|
||||||
|
return jdbc.query(sql.toString(), (rs, rowNum) -> new AgentEventRecord(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("agent_id"),
|
||||||
|
rs.getString("app_id"),
|
||||||
|
rs.getString("event_type"),
|
||||||
|
rs.getString("detail"),
|
||||||
|
rs.getTimestamp("timestamp").toInstant()
|
||||||
|
), params.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Agent lifecycle events for tracking registration, state transitions, etc.
|
||||||
|
CREATE TABLE agent_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Retention: drop agent_metrics chunks older than 90 days
|
||||||
|
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||||
|
|
||||||
|
-- Compression: compress agent_metrics chunks older than 7 days
|
||||||
|
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||||
|
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AgentEventRecord(
|
||||||
|
long id,
|
||||||
|
String agentId,
|
||||||
|
String appId,
|
||||||
|
String eventType,
|
||||||
|
String detail,
|
||||||
|
Instant timestamp
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AgentEventRepository {
|
||||||
|
|
||||||
|
void insert(String agentId, String appId, String eventType, String detail);
|
||||||
|
|
||||||
|
List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AgentEventService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentEventService.class);
|
||||||
|
|
||||||
|
private final AgentEventRepository repository;
|
||||||
|
|
||||||
|
public AgentEventService(AgentEventRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordEvent(String agentId, String appId, String eventType, String detail) {
|
||||||
|
log.debug("Recording agent event: agent={}, app={}, type={}", agentId, appId, eventType);
|
||||||
|
repository.insert(agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AgentEventRecord> queryEvents(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
return repository.query(appId, agentId, from, to, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/.npmrc
Normal file
1
ui/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
ARG REGISTRY_TOKEN
|
||||||
RUN npm ci
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \
|
||||||
|
npm ci && \
|
||||||
|
rm -f .npmrc
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Cameleer3</title>
|
<title>Cameleer3</title>
|
||||||
<script src="/config.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
451
ui/package-lock.json
generated
451
ui/package-lock.json
generated
@@ -8,14 +8,13 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "^0.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"panzoom": "^9.4.3",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"swagger-ui-dist": "^5.32.0",
|
"swagger-ui-dist": "^5.32.0",
|
||||||
"uplot": "^1.6.32",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -197,23 +196,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -274,10 +273,25 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cameleer/design-system": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.1/design-system-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-8rMAp7JhZBlAw4jcTnSBLuZe8cd94lPAgL96KDtVIk2QpXKdsJLoVfk7CuPG635/h6pu4YKplfBhJmKpsS8A8g==",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -287,9 +301,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -584,20 +598,10 @@
|
|||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/runtime": {
|
|
||||||
"version": "0.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
|
||||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.115.0",
|
"version": "0.120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -636,9 +640,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core": {
|
"node_modules/@redocly/openapi-core": {
|
||||||
"version": "1.34.10",
|
"version": "1.34.11",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
|
||||||
"integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==",
|
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -681,9 +685,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -698,9 +702,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -715,9 +719,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -732,9 +736,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -749,9 +753,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -766,9 +770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -783,9 +787,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -800,9 +804,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -817,9 +821,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -834,9 +838,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -851,9 +855,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -868,9 +872,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -885,9 +889,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -902,9 +906,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -919,9 +923,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -950,9 +954,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.91.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
||||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
"integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -960,12 +964,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.21",
|
"version": "5.91.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.91.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1031,17 +1035,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||||
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
|
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/type-utils": "8.57.0",
|
"@typescript-eslint/type-utils": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0",
|
"@typescript-eslint/utils": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
@@ -1054,7 +1058,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.57.0",
|
"@typescript-eslint/parser": "^8.57.1",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
@@ -1070,16 +1074,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
|
||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1095,14 +1099,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
|
||||||
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
|
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.57.0",
|
"@typescript-eslint/tsconfig-utils": "^8.57.1",
|
||||||
"@typescript-eslint/types": "^8.57.0",
|
"@typescript-eslint/types": "^8.57.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1117,14 +1121,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
|
||||||
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
|
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0"
|
"@typescript-eslint/visitor-keys": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1135,9 +1139,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
|
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1152,15 +1156,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
|
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0",
|
"@typescript-eslint/utils": "8.57.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
},
|
},
|
||||||
@@ -1177,9 +1181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
|
||||||
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
|
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1191,16 +1195,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
|
||||||
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
|
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.57.0",
|
"@typescript-eslint/project-service": "8.57.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.57.0",
|
"@typescript-eslint/tsconfig-utils": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -1271,16 +1275,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
|
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0"
|
"@typescript-eslint/typescript-estree": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1295,13 +1299,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
|
||||||
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
|
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1401,15 +1405,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/amator": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bezier-easing": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-colors": {
|
"node_modules/ansi-colors": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
@@ -1451,9 +1446,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.9",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
|
||||||
"integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
|
"integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1463,12 +1458,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bezier-easing": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -1525,9 +1514,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001778",
|
"version": "1.0.30001780",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
||||||
"integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
|
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1681,9 +1670,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.321",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||||
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
|
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -1978,9 +1967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -2597,12 +2586,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ngraph.events": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -2709,17 +2692,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/panzoom": {
|
|
||||||
"version": "9.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz",
|
|
||||||
"integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"amator": "^1.1.0",
|
|
||||||
"ngraph.events": "^1.2.2",
|
|
||||||
"wheel": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -2893,6 +2865,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -2914,14 +2902,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.115.0",
|
"@oxc-project/types": "=0.120.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2930,27 +2918,27 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -3036,9 +3024,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
"node_modules/swagger-ui-dist": {
|
||||||
"version": "5.32.0",
|
"version": "5.32.1",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz",
|
||||||
"integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==",
|
"integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scarf/scarf": "=1.4.0"
|
"@scarf/scarf": "=1.4.0"
|
||||||
@@ -3062,9 +3050,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3123,16 +3111,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
|
||||||
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==",
|
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/parser": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0"
|
"@typescript-eslint/utils": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3184,12 +3172,6 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uplot": {
|
|
||||||
"version": "1.6.32",
|
|
||||||
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
|
||||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@@ -3208,17 +3190,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/runtime": "0.115.0",
|
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"rolldown": "1.0.0-rc.9",
|
"rolldown": "1.0.0-rc.10",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3235,7 +3216,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
"@vitejs/devtools": "^0.1.0",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
@@ -3286,12 +3267,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wheel": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3379,9 +3354,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.11",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
|
|||||||
@@ -5,21 +5,20 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
"generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
||||||
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
|
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "^0.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"panzoom": "^9.4.3",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"swagger-ui-dist": "^5.32.0",
|
"swagger-ui-dist": "^5.32.0",
|
||||||
"uplot": "^1.6.32",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -8,18 +10,18 @@ export interface AuditEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
category: string;
|
category: string;
|
||||||
target: string;
|
target: string;
|
||||||
detail: Record<string, unknown>;
|
detail: Record<string, unknown> | null;
|
||||||
result: string;
|
result: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
username?: string;
|
username?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: string;
|
order?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -34,21 +36,25 @@ export interface AuditLogResponse {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuditLog(params: AuditLogParams) {
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.from) query.set('from', params.from);
|
|
||||||
if (params.to) query.set('to', params.to);
|
|
||||||
if (params.username) query.set('username', params.username);
|
|
||||||
if (params.category) query.set('category', params.category);
|
|
||||||
if (params.search) query.set('search', params.search);
|
|
||||||
if (params.sort) query.set('sort', params.sort);
|
|
||||||
if (params.order) query.set('order', params.order);
|
|
||||||
if (params.page !== undefined) query.set('page', String(params.page));
|
|
||||||
if (params.size !== undefined) query.set('size', String(params.size));
|
|
||||||
const qs = query.toString();
|
|
||||||
|
|
||||||
|
export function useAuditLog(params: AuditLogParams = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'audit', params],
|
queryKey: ['admin', 'audit', params],
|
||||||
queryFn: () => adminFetch<AuditLogResponse>(`/audit${qs ? `?${qs}` : ''}`),
|
queryFn: () => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.username) qs.set('username', params.username);
|
||||||
|
if (params.category) qs.set('category', params.category);
|
||||||
|
if (params.search) qs.set('search', params.search);
|
||||||
|
if (params.from) qs.set('from', params.from);
|
||||||
|
if (params.to) qs.set('to', params.to);
|
||||||
|
if (params.sort) qs.set('sort', params.sort);
|
||||||
|
if (params.order) qs.set('order', params.order);
|
||||||
|
if (params.page !== undefined) qs.set('page', String(params.page));
|
||||||
|
if (params.size !== undefined) qs.set('size', String(params.size));
|
||||||
|
const query = qs.toString();
|
||||||
|
return adminFetch<AuditLogResponse>(`/audit${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DatabaseStatus {
|
export interface DatabaseStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
version: string;
|
version: string | null;
|
||||||
host: string;
|
host: string | null;
|
||||||
schema: string;
|
schema: string | null;
|
||||||
timescaleDb: boolean;
|
timescaleDb: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PoolStats {
|
export interface PoolStats {
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
idleConnections: number;
|
idleConnections: number;
|
||||||
pendingThreads: number;
|
threadsAwaitingConnection: number;
|
||||||
maxPoolSize: number;
|
connectionTimeout: number;
|
||||||
maxWaitMs: number;
|
maximumPoolSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
@@ -33,18 +35,21 @@ export interface ActiveQuery {
|
|||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useDatabaseStatus() {
|
export function useDatabaseStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'status'],
|
queryKey: ['admin', 'database', 'status'],
|
||||||
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
|
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDatabasePool() {
|
export function useConnectionPool() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'pool'],
|
queryKey: ['admin', 'database', 'pool'],
|
||||||
queryFn: () => adminFetch<PoolStats>('/database/pool'),
|
queryFn: () => adminFetch<PoolStats>('/database/pool'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,23 +57,27 @@ export function useDatabaseTables() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'tables'],
|
queryKey: ['admin', 'database', 'tables'],
|
||||||
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
|
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
|
||||||
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDatabaseQueries() {
|
export function useActiveQueries() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'queries'],
|
queryKey: ['admin', 'database', 'queries'],
|
||||||
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
|
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 5_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useKillQuery() {
|
export function useKillQuery() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (pid: number) => {
|
mutationFn: (pid: number) =>
|
||||||
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
|
adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OpenSearchStatus {
|
export interface OpenSearchStatus {
|
||||||
reachable: boolean;
|
connected: boolean;
|
||||||
clusterHealth: string;
|
clusterHealth: string;
|
||||||
version: string;
|
version: string | null;
|
||||||
nodeCount: number;
|
numberOfNodes: number;
|
||||||
host: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineStats {
|
export interface PipelineStats {
|
||||||
queueDepth: number;
|
queueDepth: number;
|
||||||
maxQueueSize: number;
|
maxQueueSize: number;
|
||||||
indexedCount: number;
|
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
indexedCount: number;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
indexingRate: number;
|
indexingRate: number;
|
||||||
lastIndexedAt: string | null;
|
lastIndexedAt: string | null;
|
||||||
@@ -21,15 +23,15 @@ export interface PipelineStats {
|
|||||||
|
|
||||||
export interface IndexInfo {
|
export interface IndexInfo {
|
||||||
name: string;
|
name: string;
|
||||||
health: string;
|
|
||||||
docCount: number;
|
docCount: number;
|
||||||
size: string;
|
size: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
|
health: string;
|
||||||
primaryShards: number;
|
primaryShards: number;
|
||||||
replicaShards: number;
|
replicas: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndicesPageResponse {
|
export interface IndicesPage {
|
||||||
indices: IndexInfo[];
|
indices: IndexInfo[];
|
||||||
totalIndices: number;
|
totalIndices: number;
|
||||||
totalDocs: number;
|
totalDocs: number;
|
||||||
@@ -44,20 +46,17 @@ export interface PerformanceStats {
|
|||||||
requestCacheHitRate: number;
|
requestCacheHitRate: number;
|
||||||
searchLatencyMs: number;
|
searchLatencyMs: number;
|
||||||
indexingLatencyMs: number;
|
indexingLatencyMs: number;
|
||||||
jvmHeapUsedBytes: number;
|
heapUsedBytes: number;
|
||||||
jvmHeapMaxBytes: number;
|
heapMaxBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndicesParams {
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
search?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpenSearchStatus() {
|
export function useOpenSearchStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'status'],
|
queryKey: ['admin', 'opensearch', 'status'],
|
||||||
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
|
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,42 +64,41 @@ export function usePipelineStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'pipeline'],
|
queryKey: ['admin', 'opensearch', 'pipeline'],
|
||||||
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
|
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIndices(params: IndicesParams) {
|
export function useOpenSearchIndices(page = 0, size = 20, search = '') {
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.search) query.set('search', params.search);
|
|
||||||
if (params.page !== undefined) query.set('page', String(params.page));
|
|
||||||
if (params.size !== undefined) query.set('size', String(params.size));
|
|
||||||
const qs = query.toString();
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'indices', params],
|
queryKey: ['admin', 'opensearch', 'indices', page, size, search],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
adminFetch<IndicesPageResponse>(
|
const params = new URLSearchParams();
|
||||||
`/opensearch/indices${qs ? `?${qs}` : ''}`,
|
params.set('page', String(page));
|
||||||
),
|
params.set('size', String(size));
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
return adminFetch<IndicesPage>(`/opensearch/indices?${params}`);
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePerformanceStats() {
|
export function useOpenSearchPerformance() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'performance'],
|
queryKey: ['admin', 'opensearch', 'performance'],
|
||||||
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
|
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useDeleteIndex() {
|
export function useDeleteIndex() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (indexName: string) => {
|
mutationFn: (indexName: string) =>
|
||||||
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, {
|
adminFetch<void>(`/opensearch/indices/${indexName}`, { method: 'DELETE' }),
|
||||||
method: 'DELETE',
|
onSuccess: () => {
|
||||||
});
|
qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
// ─── Types ───
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RoleSummary {
|
export interface RoleSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
system: boolean;
|
scope: string;
|
||||||
source: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSummary {
|
export interface GroupSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
parentGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
provider: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDetail {
|
export interface UserDetail {
|
||||||
@@ -33,17 +32,6 @@ export interface UserDetail {
|
|||||||
effectiveGroups: GroupSummary[];
|
effectiveGroups: GroupSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupDetail {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentGroupId: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
directRoles: RoleSummary[];
|
|
||||||
effectiveRoles: RoleSummary[];
|
|
||||||
members: UserSummary[];
|
|
||||||
childGroups: GroupSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleDetail {
|
export interface RoleDetail {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,6 +44,53 @@ export interface RoleDetail {
|
|||||||
effectivePrincipals: UserSummary[];
|
effectivePrincipals: UserSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentGroupId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
directRoles: RoleSummary[];
|
||||||
|
effectiveRoles: RoleSummary[];
|
||||||
|
members: UserSummary[];
|
||||||
|
childGroups: GroupSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupRequest {
|
||||||
|
name: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGroupRequest {
|
||||||
|
name: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats Hook ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RbacStats {
|
export interface RbacStats {
|
||||||
userCount: number;
|
userCount: number;
|
||||||
activeUserCount: number;
|
activeUserCount: number;
|
||||||
@@ -64,53 +99,6 @@ export interface RbacStats {
|
|||||||
roleCount: number;
|
roleCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Query hooks ───
|
|
||||||
|
|
||||||
export function useUsers() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'users'],
|
|
||||||
queryFn: () => adminFetch<UserDetail[]>('/users'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUser(userId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'users', userId],
|
|
||||||
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGroups() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'groups'],
|
|
||||||
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGroup(groupId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'groups', groupId],
|
|
||||||
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
|
|
||||||
enabled: !!groupId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRoles() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'roles'],
|
|
||||||
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRole(roleId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'roles', roleId],
|
|
||||||
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
|
|
||||||
enabled: !!roleId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRbacStats() {
|
export function useRbacStats() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'rbac', 'stats'],
|
queryKey: ['admin', 'rbac', 'stats'],
|
||||||
@@ -118,162 +106,69 @@ export function useRbacStats() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mutation hooks ───
|
// ── User Query Hooks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useAssignRoleToUser() {
|
export function useUsers() {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'users'],
|
||||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
queryFn: () => adminFetch<UserDetail[]>('/users'),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveRoleFromUser() {
|
export function useUser(userId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'users', userId],
|
||||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
queryFn: () => adminFetch<UserDetail>(`/users/${userId}`),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
|
enabled: !!userId,
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAddUserToGroup() {
|
// ── Role Query Hooks ───────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
export function useRoles() {
|
||||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
return useQuery({
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
|
queryKey: ['admin', 'roles'],
|
||||||
onSuccess: () => {
|
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveUserFromGroup() {
|
export function useRole(roleId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'roles', roleId],
|
||||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
|
enabled: !!roleId,
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateGroup() {
|
// ── Group Query Hooks ──────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
export function useGroups() {
|
||||||
mutationFn: (data: { name: string; parentGroupId?: string }) =>
|
return useQuery({
|
||||||
adminFetch<{ id: string }>('/groups', {
|
queryKey: ['admin', 'groups'],
|
||||||
method: 'POST',
|
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateGroup() {
|
export function useGroup(groupId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'groups', groupId],
|
||||||
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
|
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
|
||||||
adminFetch(`/groups/${id}`, {
|
enabled: !!groupId,
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteGroup() {
|
// ── User Mutation Hooks ────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAssignRoleToGroup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
|
||||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveRoleFromGroup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
|
||||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
|
|
||||||
adminFetch<{ id: string }>('/roles', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
|
|
||||||
adminFetch(`/roles/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateUser() {
|
export function useCreateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
|
mutationFn: (req: CreateUserRequest) =>
|
||||||
adminFetch<UserDetail>('/users', {
|
adminFetch<UserDetail>('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(req),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -281,13 +176,13 @@ export function useCreateUser() {
|
|||||||
export function useUpdateUser() {
|
export function useUpdateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
|
mutationFn: ({ userId, ...req }: UpdateUserRequest & { userId: string }) =>
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}`, {
|
adminFetch<void>(`/users/${userId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(req),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -296,9 +191,163 @@ export function useDeleteUser() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (userId: string) =>
|
mutationFn: (userId: string) =>
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
adminFetch<void>(`/users/${userId}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignRoleToUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveRoleFromUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddUserToGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveUserFromGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Role Mutation Hooks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (req: CreateRoleRequest) =>
|
||||||
|
adminFetch<{ id: string }>('/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...req }: UpdateRoleRequest & { id: string }) =>
|
||||||
|
adminFetch<void>(`/roles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
adminFetch<void>(`/roles/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group Mutation Hooks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (req: CreateGroupRequest) =>
|
||||||
|
adminFetch<{ id: string }>('/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...req }: UpdateGroupRequest & { id: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
adminFetch<void>(`/groups/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignRoleToGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveRoleFromGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DatabaseThresholds {
|
export interface DatabaseThresholds {
|
||||||
connectionPoolWarning: number;
|
connectionPoolWarning: number;
|
||||||
connectionPoolCritical: number;
|
connectionPoolCritical: number;
|
||||||
@@ -24,6 +26,8 @@ export interface ThresholdConfig {
|
|||||||
opensearch: OpenSearchThresholds;
|
opensearch: OpenSearchThresholds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useThresholds() {
|
export function useThresholds() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'thresholds'],
|
queryKey: ['admin', 'thresholds'],
|
||||||
@@ -31,15 +35,18 @@ export function useThresholds() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveThresholds() {
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateThresholds() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: ThresholdConfig) => {
|
mutationFn: (config: ThresholdConfig) =>
|
||||||
await adminFetch<ThresholdConfig>('/thresholds', {
|
adminFetch<ThresholdConfig>('/thresholds', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(config),
|
||||||
});
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../client';
|
import { api } from '../client';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
export function useAgents(status?: string) {
|
export function useAgents(status?: string, group?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['agents', status],
|
queryKey: ['agents', status, group],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/agents', {
|
const { data, error } = await api.GET('/agents', {
|
||||||
params: { query: status ? { status } : {} },
|
params: { query: { ...(status ? { status } : {}), ...(group ? { group } : {}) } },
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Failed to load agents');
|
if (error) throw new Error('Failed to load agents');
|
||||||
return data!;
|
return data!;
|
||||||
},
|
},
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentEvents(appId?: string, agentId?: string, limit = 50) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agents', 'events', appId, agentId, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appId) params.set('appId', appId);
|
||||||
|
if (agentId) params.set('agentId', agentId);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load agent events');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
43
ui/src/api/queries/catalog.ts
Normal file
43
ui/src/api/queries/catalog.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
|
export function useRouteCatalog() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['routes', 'catalog'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/routes/catalog`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load route catalog');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouteMetrics(from?: string, to?: string, appId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['routes', 'metrics', from, to, appId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
if (to) params.set('to', to);
|
||||||
|
if (appId) params.set('appId', appId);
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/routes/metrics?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load route metrics');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../client';
|
|
||||||
import type { OidcAdminConfigRequest } from '../types';
|
|
||||||
|
|
||||||
export function useOidcConfig() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'oidc'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, error } = await api.GET('/admin/oidc');
|
|
||||||
if (error) throw new Error('Failed to load OIDC config');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveOidcConfig() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body: OidcAdminConfigRequest) => {
|
|
||||||
const { data, error } = await api.PUT('/admin/oidc', { body });
|
|
||||||
if (error) throw new Error('Failed to save OIDC config');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTestOidcConnection() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { data, error } = await api.POST('/admin/oidc/test');
|
|
||||||
if (error) throw new Error('OIDC test failed');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteOidcConfig() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { error } = await api.DELETE('/admin/oidc');
|
|
||||||
if (error) throw new Error('Failed to delete OIDC config');
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
3387
ui/src/api/schema.d.ts
vendored
3387
ui/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,8 @@ export type ErrorResponse = components['schemas']['ErrorResponse'];
|
|||||||
export type DiagramLayout = components['schemas']['DiagramLayout'];
|
export type DiagramLayout = components['schemas']['DiagramLayout'];
|
||||||
export type PositionedNode = components['schemas']['PositionedNode'];
|
export type PositionedNode = components['schemas']['PositionedNode'];
|
||||||
export type PositionedEdge = components['schemas']['PositionedEdge'];
|
export type PositionedEdge = components['schemas']['PositionedEdge'];
|
||||||
|
export type AppCatalogEntry = components['schemas']['AppCatalogEntry'];
|
||||||
|
export type RouteSummary = components['schemas']['RouteSummary'];
|
||||||
|
export type AgentSummary = components['schemas']['AgentSummary'];
|
||||||
|
export type RouteMetrics = components['schemas']['RouteMetrics'];
|
||||||
|
export type AgentEventResponse = components['schemas']['AgentEventResponse'];
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
.page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 40px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
animation: fadeIn 0.3s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--amber);
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
background: var(--amber);
|
|
||||||
color: #0a0e17;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit:hover {
|
|
||||||
background: var(--amber-hover);
|
|
||||||
border-color: var(--amber-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssoButton {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssoButton:hover {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssoButton:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dividerText {
|
|
||||||
position: relative;
|
|
||||||
top: -0.65em;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useState } from 'react';
|
|||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import styles from './LoginPage.module.css';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
|
|
||||||
interface OidcInfo {
|
interface OidcInfo {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -50,62 +50,54 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
|
||||||
<form className={styles.card} onSubmit={handleSubmit}>
|
<Card>
|
||||||
<div className={styles.logo}>
|
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
|
||||||
<path d="M12 6v6l4 2" />
|
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
</svg>
|
Sign in to access the observability dashboard
|
||||||
cameleer3
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
|
|
||||||
|
|
||||||
{oidc && (
|
{oidc && (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
|
||||||
className={styles.ssoButton}
|
|
||||||
type="button"
|
|
||||||
onClick={handleOidcLogin}
|
|
||||||
disabled={oidcLoading}
|
|
||||||
>
|
|
||||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||||
</button>
|
</Button>
|
||||||
<div className={styles.divider}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
|
||||||
<span className={styles.dividerText}>or</span>
|
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>or</span>
|
||||||
|
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.field}>
|
<FormField label="Username">
|
||||||
<label className={styles.label}>Username</label>
|
<Input
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<div className={styles.field}>
|
<FormField label="Password">
|
||||||
<label className={styles.label}>Password</label>
|
<Input
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<button className={styles.submit} type="submit" disabled={loading || !username || !password}>
|
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
|
||||||
</form>
|
</form>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router';
|
import { Navigate, useNavigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
import styles from './LoginPage.module.css';
|
import { Card, Spinner, Alert, Button } from '@cameleer/design-system';
|
||||||
|
|
||||||
export function OidcCallback() {
|
export function OidcCallback() {
|
||||||
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
|
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
|
||||||
@@ -36,29 +36,21 @@ export function OidcCallback() {
|
|||||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
|
||||||
<div className={styles.card}>
|
<Card>
|
||||||
<div className={styles.logo}>
|
<div style={{ padding: '2rem', textAlign: 'center', minWidth: 320 }}>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<h2 style={{ marginBottom: '1rem' }}>cameleer3</h2>
|
||||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
{loading && <Spinner />}
|
||||||
<path d="M12 6v6l4 2" />
|
|
||||||
</svg>
|
|
||||||
cameleer3
|
|
||||||
</div>
|
|
||||||
{loading && <div className={styles.subtitle}>Completing sign-in...</div>}
|
|
||||||
{error && (
|
{error && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.error}>{error}</div>
|
<Alert variant="error">{error}</Alert>
|
||||||
<button
|
<Button variant="secondary" onClick={() => navigate('/login')} style={{ marginTop: 16 }}>
|
||||||
className={styles.submit}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
onClick={() => navigate('/login')}
|
|
||||||
>
|
|
||||||
Back to Login
|
Back to Login
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useAuth } from './use-auth';
|
|||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
// Initialize auth hooks (auto-refresh, API client wiring)
|
|
||||||
useAuth();
|
useAuth();
|
||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export function useAuth() {
|
|||||||
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
|
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Wire onUnauthorized handler (needs navigate from router context)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configureAuth({
|
configureAuth({
|
||||||
onUnauthorized: async () => {
|
onUnauthorized: async () => {
|
||||||
@@ -20,7 +19,6 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// Auto-refresh: check token expiry every 30s
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
@@ -29,12 +27,11 @@ export function useAuth() {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
const expiresIn = payload.exp * 1000 - Date.now();
|
const expiresIn = payload.exp * 1000 - Date.now();
|
||||||
// Refresh when less than 5 minutes remaining
|
|
||||||
if (expiresIn < 5 * 60 * 1000) {
|
if (expiresIn < 5 * 60 * 1000) {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token parse failure — ignore, will fail on next API call
|
// Token parse failure
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
|||||||
84
ui/src/components/LayoutShell.tsx
Normal file
84
ui/src/components/LayoutShell.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
|
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, useCommandPalette } from '@cameleer/design-system';
|
||||||
|
import { useRouteCatalog } from '../api/queries/catalog';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import type { SidebarApp } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
function LayoutContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { data: catalog } = useRouteCatalog();
|
||||||
|
const { username, roles } = useAuthStore();
|
||||||
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||||
|
|
||||||
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||||
|
if (!catalog) return [];
|
||||||
|
return catalog.map((app: any) => ({
|
||||||
|
id: app.appId,
|
||||||
|
name: app.appId,
|
||||||
|
health: app.health as 'live' | 'stale' | 'dead',
|
||||||
|
exchangeCount: app.exchangeCount,
|
||||||
|
routes: (app.routes || []).map((r: any) => ({
|
||||||
|
id: r.routeId,
|
||||||
|
name: r.routeId,
|
||||||
|
exchangeCount: r.exchangeCount,
|
||||||
|
})),
|
||||||
|
agents: (app.agents || []).map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
status: a.status as 'live' | 'stale' | 'dead',
|
||||||
|
tps: a.tps,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const breadcrumb = useMemo(() => {
|
||||||
|
const parts = location.pathname.split('/').filter(Boolean);
|
||||||
|
return parts.map((part, i) => ({
|
||||||
|
label: part,
|
||||||
|
href: '/' + parts.slice(0, i + 1).join('/'),
|
||||||
|
}));
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handlePaletteSelect = useCallback((result: any) => {
|
||||||
|
if (result.path) navigate(result.path);
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}, [navigate, setPaletteOpen]);
|
||||||
|
|
||||||
|
const isAdmin = roles.includes('ADMIN');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
apps={sidebarApps}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
user={username ? { name: username } : undefined}
|
||||||
|
/>
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onClose={() => setPaletteOpen(false)}
|
||||||
|
onSelect={handlePaletteSelect}
|
||||||
|
data={[]}
|
||||||
|
/>
|
||||||
|
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutShell() {
|
||||||
|
return (
|
||||||
|
<CommandPaletteProvider>
|
||||||
|
<GlobalFilterProvider>
|
||||||
|
<LayoutContent />
|
||||||
|
</GlobalFilterProvider>
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 24px;
|
|
||||||
width: 420px;
|
|
||||||
max-width: 90vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnCancel {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnCancel:hover {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDelete {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--rose-dim);
|
|
||||||
color: var(--rose);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDelete:hover:not(:disabled) {
|
|
||||||
background: var(--rose-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDelete:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import styles from './ConfirmDeleteDialog.module.css';
|
|
||||||
|
|
||||||
interface ConfirmDeleteDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
resourceName: string;
|
|
||||||
resourceType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDeleteDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
resourceName,
|
|
||||||
resourceType,
|
|
||||||
}: ConfirmDeleteDialogProps) {
|
|
||||||
const [confirmText, setConfirmText] = useState('');
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const canDelete = confirmText === resourceName;
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setConfirmText('');
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
if (!canDelete) return;
|
|
||||||
setConfirmText('');
|
|
||||||
onConfirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.overlay} onClick={handleClose}>
|
|
||||||
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h3 className={styles.title}>Confirm Deletion</h3>
|
|
||||||
<p className={styles.message}>
|
|
||||||
Delete {resourceType} ‘{resourceName}’? This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<label className={styles.label}>
|
|
||||||
Type <strong>{resourceName}</strong> to confirm:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={confirmText}
|
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
|
||||||
placeholder={resourceName}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button type="button" className={styles.btnCancel} onClick={handleClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.btnDelete}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={!canDelete}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
.card {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerClickable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerClickable:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevronOpen {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoIndicator {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 99px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshBtn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshBtn:hover {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshBtn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshing {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
|
||||||
import styles from './RefreshableCard.module.css';
|
|
||||||
|
|
||||||
interface RefreshableCardProps {
|
|
||||||
title: string;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
isRefreshing?: boolean;
|
|
||||||
autoRefresh?: boolean;
|
|
||||||
collapsible?: boolean;
|
|
||||||
defaultCollapsed?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RefreshableCard({
|
|
||||||
title,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
autoRefresh,
|
|
||||||
collapsible,
|
|
||||||
defaultCollapsed,
|
|
||||||
children,
|
|
||||||
}: RefreshableCardProps) {
|
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
|
||||||
|
|
||||||
const headerProps = collapsible
|
|
||||||
? {
|
|
||||||
onClick: () => setCollapsed((c) => !c),
|
|
||||||
className: `${styles.header} ${styles.headerClickable}`,
|
|
||||||
role: 'button' as const,
|
|
||||||
tabIndex: 0,
|
|
||||||
onKeyDown: (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
setCollapsed((c) => !c);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { className: styles.header };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.card}>
|
|
||||||
<div {...headerProps}>
|
|
||||||
<div className={styles.titleRow}>
|
|
||||||
{collapsible && (
|
|
||||||
<span className={`${styles.chevron} ${collapsed ? '' : styles.chevronOpen}`}>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<h3 className={styles.title}>{title}</h3>
|
|
||||||
{autoRefresh && <span className={styles.autoIndicator}>auto</span>}
|
|
||||||
</div>
|
|
||||||
{onRefresh && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.refreshBtn} ${isRefreshing ? styles.refreshing : ''}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRefresh();
|
|
||||||
}}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!collapsed && <div className={styles.body}>{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthy {
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
background: #eab308;
|
|
||||||
}
|
|
||||||
|
|
||||||
.critical {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unknown {
|
|
||||||
background: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import styles from './StatusBadge.module.css';
|
|
||||||
|
|
||||||
export type Status = 'healthy' | 'warning' | 'critical' | 'unknown';
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status: Status;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
|
||||||
return (
|
|
||||||
<span className={styles.badge}>
|
|
||||||
<span className={`${styles.dot} ${styles[status]}`} />
|
|
||||||
{label && <span className={styles.label}>{label}</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useRef, useEffect, useMemo } from 'react';
|
|
||||||
import uPlot from 'uplot';
|
|
||||||
import 'uplot/dist/uPlot.min.css';
|
|
||||||
import { baseOpts, chartColors } from './theme';
|
|
||||||
import type { TimeseriesBucket } from '../../api/types';
|
|
||||||
|
|
||||||
interface DurationHistogramProps {
|
|
||||||
buckets: TimeseriesBucket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DurationHistogram({ buckets }: DurationHistogramProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<uPlot | null>(null);
|
|
||||||
|
|
||||||
// Build histogram bins from avg durations
|
|
||||||
const histData = useMemo(() => {
|
|
||||||
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
|
|
||||||
if (durations.length < 2) return null;
|
|
||||||
|
|
||||||
const min = Math.min(...durations);
|
|
||||||
const max = Math.max(...durations);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const binCount = Math.min(20, durations.length);
|
|
||||||
const binSize = range / binCount;
|
|
||||||
|
|
||||||
const bins = new Array(binCount).fill(0);
|
|
||||||
const labels = new Array(binCount).fill(0);
|
|
||||||
for (let i = 0; i < binCount; i++) {
|
|
||||||
labels[i] = Math.round(min + binSize * i + binSize / 2);
|
|
||||||
}
|
|
||||||
for (const d of durations) {
|
|
||||||
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
|
|
||||||
bins[idx]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { xs: labels, counts: bins };
|
|
||||||
}, [buckets]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || !histData) return;
|
|
||||||
const el = containerRef.current;
|
|
||||||
const w = el.clientWidth || 600;
|
|
||||||
|
|
||||||
const opts: uPlot.Options = {
|
|
||||||
...baseOpts(w, 220),
|
|
||||||
width: w,
|
|
||||||
height: 220,
|
|
||||||
scales: {
|
|
||||||
x: { time: false },
|
|
||||||
},
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { show: false },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
gap: 8,
|
|
||||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
size: 40,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{ label: 'Duration (ms)' },
|
|
||||||
{
|
|
||||||
label: 'Count',
|
|
||||||
stroke: chartColors.cyan,
|
|
||||||
fill: `${chartColors.cyan}30`,
|
|
||||||
width: 2,
|
|
||||||
paths: (u, seriesIdx, idx0, idx1) => {
|
|
||||||
const path = new Path2D();
|
|
||||||
const fillPath = new Path2D();
|
|
||||||
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
|
|
||||||
const yBase = u.valToPos(0, 'y', true);
|
|
||||||
|
|
||||||
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
|
|
||||||
for (let i = idx0; i <= idx1; i++) {
|
|
||||||
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
|
|
||||||
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
|
|
||||||
path.rect(x, y, barWidth, yBase - y);
|
|
||||||
fillPath.rect(x, y, barWidth, yBase - y);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stroke: path, fill: fillPath };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
}, [histData]);
|
|
||||||
|
|
||||||
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
|
|
||||||
|
|
||||||
return <div ref={containerRef} />;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
|
||||||
import uPlot from 'uplot';
|
|
||||||
import 'uplot/dist/uPlot.min.css';
|
|
||||||
import { baseOpts, chartColors } from './theme';
|
|
||||||
import type { TimeseriesBucket } from '../../api/types';
|
|
||||||
|
|
||||||
interface LatencyHeatmapProps {
|
|
||||||
buckets: TimeseriesBucket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<uPlot | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || buckets.length < 2) return;
|
|
||||||
const el = containerRef.current;
|
|
||||||
const w = el.clientWidth || 600;
|
|
||||||
|
|
||||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
|
||||||
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
|
|
||||||
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
|
|
||||||
|
|
||||||
const opts: uPlot.Options = {
|
|
||||||
...baseOpts(w, 220),
|
|
||||||
width: w,
|
|
||||||
height: 220,
|
|
||||||
series: [
|
|
||||||
{ label: 'Time' },
|
|
||||||
{
|
|
||||||
label: 'Avg Duration',
|
|
||||||
stroke: chartColors.cyan,
|
|
||||||
width: 2,
|
|
||||||
dash: [4, 2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'P99 Duration',
|
|
||||||
stroke: chartColors.amber,
|
|
||||||
fill: `${chartColors.amber}15`,
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { show: false },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
size: 50,
|
|
||||||
gap: 8,
|
|
||||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
}, [buckets]);
|
|
||||||
|
|
||||||
if (buckets.length < 2) return null;
|
|
||||||
|
|
||||||
return <div ref={containerRef} />;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useRef, useEffect, useMemo } from 'react';
|
|
||||||
import uPlot from 'uplot';
|
|
||||||
import 'uplot/dist/uPlot.min.css';
|
|
||||||
import { sparkOpts, accentHex } from './theme';
|
|
||||||
|
|
||||||
interface MiniChartProps {
|
|
||||||
data: number[];
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MiniChart({ data, color }: MiniChartProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<uPlot | null>(null);
|
|
||||||
|
|
||||||
// Trim first/last buckets (partial time windows) like the old Sparkline
|
|
||||||
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
|
|
||||||
|
|
||||||
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
|
|
||||||
? color
|
|
||||||
: accentHex(color);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || trimmed.length < 2) return;
|
|
||||||
|
|
||||||
const el = containerRef.current;
|
|
||||||
const w = el.clientWidth || 200;
|
|
||||||
const h = 24;
|
|
||||||
|
|
||||||
// x-axis: simple index values
|
|
||||||
const xs = Float64Array.from(trimmed, (_, i) => i);
|
|
||||||
const ys = Float64Array.from(trimmed);
|
|
||||||
|
|
||||||
const opts: uPlot.Options = {
|
|
||||||
...sparkOpts(w, h),
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
series: [
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
stroke: resolvedColor,
|
|
||||||
width: 1.5,
|
|
||||||
fill: `${resolvedColor}30`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (chartRef.current) {
|
|
||||||
chartRef.current.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
}, [trimmed, resolvedColor]);
|
|
||||||
|
|
||||||
if (trimmed.length < 2) return null;
|
|
||||||
|
|
||||||
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
|
||||||
import uPlot from 'uplot';
|
|
||||||
import 'uplot/dist/uPlot.min.css';
|
|
||||||
import { baseOpts, chartColors } from './theme';
|
|
||||||
import type { TimeseriesBucket } from '../../api/types';
|
|
||||||
|
|
||||||
interface ThroughputChartProps {
|
|
||||||
buckets: TimeseriesBucket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThroughputChart({ buckets }: ThroughputChartProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<uPlot | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || buckets.length < 2) return;
|
|
||||||
const el = containerRef.current;
|
|
||||||
const w = el.clientWidth || 600;
|
|
||||||
|
|
||||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
|
||||||
const totals = buckets.map((b) => b.totalCount ?? 0);
|
|
||||||
const failed = buckets.map((b) => b.failedCount ?? 0);
|
|
||||||
|
|
||||||
const opts: uPlot.Options = {
|
|
||||||
...baseOpts(w, 220),
|
|
||||||
width: w,
|
|
||||||
height: 220,
|
|
||||||
series: [
|
|
||||||
{ label: 'Time' },
|
|
||||||
{
|
|
||||||
label: 'Total',
|
|
||||||
stroke: chartColors.amber,
|
|
||||||
fill: `${chartColors.amber}20`,
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Failed',
|
|
||||||
stroke: chartColors.rose,
|
|
||||||
fill: `${chartColors.rose}20`,
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
}, [buckets]);
|
|
||||||
|
|
||||||
if (buckets.length < 2) return null;
|
|
||||||
|
|
||||||
return <div ref={containerRef} />;
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type uPlot from 'uplot';
|
|
||||||
|
|
||||||
/** Shared uPlot color tokens matching Cameleer3 design system */
|
|
||||||
export const chartColors = {
|
|
||||||
amber: '#f0b429',
|
|
||||||
cyan: '#22d3ee',
|
|
||||||
rose: '#f43f5e',
|
|
||||||
green: '#10b981',
|
|
||||||
blue: '#3b82f6',
|
|
||||||
purple: '#a855f7',
|
|
||||||
grid: 'rgba(30, 45, 61, 0.18)',
|
|
||||||
axis: '#4a5e7a',
|
|
||||||
text: '#8b9cb6',
|
|
||||||
bg: '#111827',
|
|
||||||
cursor: 'rgba(240, 180, 41, 0.15)',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AccentColor = keyof typeof chartColors;
|
|
||||||
|
|
||||||
/** Resolve an accent name to a CSS hex color */
|
|
||||||
export function accentHex(accent: string): string {
|
|
||||||
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Base uPlot options shared across all Cameleer3 charts */
|
|
||||||
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
cursor: {
|
|
||||||
show: true,
|
|
||||||
x: true,
|
|
||||||
y: false,
|
|
||||||
},
|
|
||||||
legend: { show: false },
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { show: false },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stroke: chartColors.axis,
|
|
||||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
|
||||||
ticks: { show: false },
|
|
||||||
font: '11px JetBrains Mono, monospace',
|
|
||||||
size: 50,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mini sparkline chart options (no axes, no cursor) */
|
|
||||||
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
cursor: { show: false },
|
|
||||||
legend: { show: false },
|
|
||||||
axes: [
|
|
||||||
{ show: false },
|
|
||||||
{ show: false },
|
|
||||||
],
|
|
||||||
scales: {
|
|
||||||
x: { time: false },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
/* ── Overlay ── */
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
background: rgba(6, 10, 19, 0.75);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 12vh;
|
|
||||||
animation: fadeIn 0.12s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .overlay {
|
|
||||||
background: rgba(247, 245, 242, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from { opacity: 0; transform: translateY(16px) scale(0.98); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInResult {
|
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ── */
|
|
||||||
.modal {
|
|
||||||
width: 680px;
|
|
||||||
max-height: 520px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: 0 16px 72px rgba(0, 0, 0, 0.5), 0 0 40px rgba(240, 180, 41, 0.04);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: slideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Input Area ── */
|
|
||||||
.inputWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: var(--amber);
|
|
||||||
flex-shrink: 0;
|
|
||||||
filter: drop-shadow(0 0 6px var(--amber-glow));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipList {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--amber-glow);
|
|
||||||
color: var(--amber);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipKey {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipRemove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--amber);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0 0 0 2px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipRemove:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
caret-color: var(--amber);
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputHint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbd {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scope Tabs ── */
|
|
||||||
.scopeTabs {
|
|
||||||
display: flex;
|
|
||||||
padding: 8px 18px 0;
|
|
||||||
gap: 2px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeTab {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeTab:hover {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeTabActive {
|
|
||||||
composes: scopeTab;
|
|
||||||
color: var(--amber);
|
|
||||||
border-bottom-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeCount {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeTabActive .scopeCount {
|
|
||||||
background: var(--amber-glow);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scopeTabDisabled {
|
|
||||||
composes: scopeTab;
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Results ── */
|
|
||||||
.results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 6px 8px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results::-webkit-scrollbar { width: 6px; }
|
|
||||||
.results::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
||||||
|
|
||||||
.groupLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 10px 12px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
animation: slideInResult 0.2s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultItem:nth-child(2) { animation-delay: 0.03s; }
|
|
||||||
.resultItem:nth-child(3) { animation-delay: 0.06s; }
|
|
||||||
.resultItem:nth-child(4) { animation-delay: 0.09s; }
|
|
||||||
.resultItem:nth-child(5) { animation-delay: 0.12s; }
|
|
||||||
|
|
||||||
.resultItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultItemSelected {
|
|
||||||
composes: resultItem;
|
|
||||||
background: var(--amber-glow);
|
|
||||||
outline: 1px solid rgba(240, 180, 41, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultItemSelected:hover {
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Result Icon ── */
|
|
||||||
.resultIcon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultIcon svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconExecution {
|
|
||||||
composes: resultIcon;
|
|
||||||
background: rgba(59, 130, 246, 0.12);
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconAgent {
|
|
||||||
composes: resultIcon;
|
|
||||||
background: var(--green-glow);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconError {
|
|
||||||
composes: resultIcon;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconRoute {
|
|
||||||
composes: resultIcon;
|
|
||||||
background: rgba(168, 85, 247, 0.12);
|
|
||||||
color: var(--purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Result Body ── */
|
|
||||||
.resultBody {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
|
||||||
color: var(--amber);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultMeta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 3px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sep {
|
|
||||||
width: 3px;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Badges ── */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeCompleted {
|
|
||||||
composes: badge;
|
|
||||||
background: var(--green-glow);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeFailed {
|
|
||||||
composes: badge;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeRunning {
|
|
||||||
composes: badge;
|
|
||||||
background: rgba(240, 180, 41, 0.12);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeDuration {
|
|
||||||
composes: badge;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeRoute {
|
|
||||||
composes: badge;
|
|
||||||
background: rgba(168, 85, 247, 0.1);
|
|
||||||
color: var(--purple);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeLive {
|
|
||||||
composes: badge;
|
|
||||||
background: var(--green-glow);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeStale {
|
|
||||||
composes: badge;
|
|
||||||
background: rgba(240, 180, 41, 0.12);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeDead {
|
|
||||||
composes: badge;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultRight {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultTime {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty / Loading ── */
|
|
||||||
.emptyState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyIcon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyText {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyHint {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingDots {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 24px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-muted);
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingDot:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.loadingDot:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
||||||
40% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ── */
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerHints {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerHint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerBrand {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.modal {
|
|
||||||
width: calc(100vw - 32px);
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useCommandPalette } from './use-command-palette';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component: only registers the global Cmd+K / Ctrl+K keyboard shortcut.
|
|
||||||
* The palette UI itself is rendered inline within SearchFilters.
|
|
||||||
*/
|
|
||||||
export function CommandPalette() {
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
const store = useCommandPalette.getState();
|
|
||||||
if (store.isOpen) {
|
|
||||||
store.close();
|
|
||||||
store.reset();
|
|
||||||
} else {
|
|
||||||
store.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import styles from './CommandPalette.module.css';
|
|
||||||
|
|
||||||
export function PaletteFooter() {
|
|
||||||
return (
|
|
||||||
<div className={styles.footer}>
|
|
||||||
<div className={styles.footerHints}>
|
|
||||||
<span className={styles.footerHint}>
|
|
||||||
<kbd className={styles.kbd}>↑</kbd>
|
|
||||||
<kbd className={styles.kbd}>↓</kbd> navigate
|
|
||||||
</span>
|
|
||||||
<span className={styles.footerHint}>
|
|
||||||
<kbd className={styles.kbd}>↵</kbd> open
|
|
||||||
</span>
|
|
||||||
<span className={styles.footerHint}>
|
|
||||||
<kbd className={styles.kbd}>tab</kbd> scope
|
|
||||||
</span>
|
|
||||||
<span className={styles.footerHint}>
|
|
||||||
<kbd className={styles.kbd}>esc</kbd> close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={styles.footerBrand}>cameleer3</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
|
||||||
import { useCommandPalette } from './use-command-palette';
|
|
||||||
import { parseFilterPrefix, checkTrailingFilter } from './utils';
|
|
||||||
import styles from './CommandPalette.module.css';
|
|
||||||
|
|
||||||
export function PaletteInput() {
|
|
||||||
const { query, filters, setQuery, addFilter, removeLastFilter, removeFilter } =
|
|
||||||
useCommandPalette();
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function handleChange(value: string) {
|
|
||||||
// Check if user typed a filter prefix like "status:failed "
|
|
||||||
const parsed = parseFilterPrefix(value);
|
|
||||||
if (parsed) {
|
|
||||||
addFilter(parsed.filter);
|
|
||||||
setQuery(parsed.remaining);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trailing = checkTrailingFilter(value);
|
|
||||||
if (trailing) {
|
|
||||||
addFilter(trailing);
|
|
||||||
setQuery('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setQuery(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === 'Backspace' && query === '' && filters.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
removeLastFilter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.inputWrap}>
|
|
||||||
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
{filters.length > 0 && (
|
|
||||||
<div className={styles.chipList}>
|
|
||||||
{filters.map((f, i) => (
|
|
||||||
<span key={f.key} className={styles.chip}>
|
|
||||||
<span className={styles.chipKey}>{f.key}:</span>
|
|
||||||
{f.value}
|
|
||||||
<button className={styles.chipRemove} onClick={() => removeFilter(i)}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'}
|
|
||||||
/>
|
|
||||||
<div className={styles.inputHint}>
|
|
||||||
<kbd className={styles.kbd}>esc</kbd> close
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import type { ExecutionSummary, AgentInstance } from '../../api/types';
|
|
||||||
import type { PaletteResult, RouteInfo } from './use-palette-search';
|
|
||||||
import { highlightMatch, formatRelativeTime } from './utils';
|
|
||||||
import { AppBadge } from '../shared/AppBadge';
|
|
||||||
import styles from './CommandPalette.module.css';
|
|
||||||
|
|
||||||
interface ResultItemProps {
|
|
||||||
result: PaletteResult;
|
|
||||||
selected: boolean;
|
|
||||||
query: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
|
||||||
const parts = highlightMatch(text, query);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{parts.map((p, i) =>
|
|
||||||
typeof p === 'string' ? (
|
|
||||||
<span key={i}>{p}</span>
|
|
||||||
) : (
|
|
||||||
<span key={i} className={styles.highlight}>{p.highlight}</span>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
|
||||||
switch (status.toUpperCase()) {
|
|
||||||
case 'COMPLETED': return styles.badgeCompleted;
|
|
||||||
case 'FAILED': return styles.badgeFailed;
|
|
||||||
case 'RUNNING': return styles.badgeRunning;
|
|
||||||
default: return styles.badge;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateBadgeClass(state: string): string {
|
|
||||||
switch (state) {
|
|
||||||
case 'LIVE': return styles.badgeLive;
|
|
||||||
case 'STALE': return styles.badgeStale;
|
|
||||||
case 'DEAD': return styles.badgeDead;
|
|
||||||
default: return styles.badge;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExecutionResult({ data, query }: { data: ExecutionSummary; query: string }) {
|
|
||||||
const isFailed = data.status === 'FAILED';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={isFailed ? styles.iconError : styles.iconExecution}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultBody}>
|
|
||||||
<div className={styles.resultTitle}>
|
|
||||||
<HighlightedText text={data.routeId} query={query} />
|
|
||||||
<span className={statusBadgeClass(data.status)}>{data.status}</span>
|
|
||||||
<span className={styles.badgeDuration}>{data.durationMs}ms</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultMeta}>
|
|
||||||
<AppBadge name={data.agentId} />
|
|
||||||
<span className={styles.sep} />
|
|
||||||
<HighlightedText text={data.executionId.slice(0, 16)} query={query} />
|
|
||||||
{data.errorMessage && (
|
|
||||||
<>
|
|
||||||
<span className={styles.sep} />
|
|
||||||
<span style={{ color: 'var(--rose)' }}>
|
|
||||||
{data.errorMessage.slice(0, 60)}
|
|
||||||
{data.errorMessage.length > 60 ? '...' : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultRight}>
|
|
||||||
<span className={styles.resultTime}>{formatRelativeTime(data.startTime)}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApplicationResult({ data, query }: { data: AgentInstance; query: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.iconAgent}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultBody}>
|
|
||||||
<div className={styles.resultTitle}>
|
|
||||||
<HighlightedText text={data.id} query={query} />
|
|
||||||
<span className={stateBadgeClass(data.status)}>{data.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultMeta}>
|
|
||||||
<span>group: {data.group}</span>
|
|
||||||
<span className={styles.sep} />
|
|
||||||
<span>last heartbeat: {formatRelativeTime(data.lastHeartbeat)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultRight}>
|
|
||||||
<span className={styles.resultTime}>Application</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteResult({ data, query }: { data: RouteInfo; query: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.iconRoute}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="6" cy="19" r="3" />
|
|
||||||
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
|
|
||||||
<circle cx="18" cy="5" r="3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultBody}>
|
|
||||||
<div className={styles.resultTitle}>
|
|
||||||
<HighlightedText text={data.routeId} query={query} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultMeta}>
|
|
||||||
<span>{data.agentIds.length} {data.agentIds.length === 1 ? 'application' : 'applications'}</span>
|
|
||||||
<span className={styles.sep} />
|
|
||||||
{data.agentIds.map((id) => <AppBadge key={id} name={id} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.resultRight}>
|
|
||||||
<span className={styles.resultTime}>Route</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResultItem({ result, selected, query, onClick }: ResultItemProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={selected ? styles.resultItemSelected : styles.resultItem}
|
|
||||||
onClick={onClick}
|
|
||||||
data-palette-item
|
|
||||||
>
|
|
||||||
{result.type === 'execution' && (
|
|
||||||
<ExecutionResult data={result.data as ExecutionSummary} query={query} />
|
|
||||||
)}
|
|
||||||
{result.type === 'application' && (
|
|
||||||
<ApplicationResult data={result.data as AgentInstance} query={query} />
|
|
||||||
)}
|
|
||||||
{result.type === 'route' && (
|
|
||||||
<RouteResult data={result.data as RouteInfo} query={query} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
|
||||||
import { useCommandPalette } from './use-command-palette';
|
|
||||||
import type { PaletteResult } from './use-palette-search';
|
|
||||||
import { ResultItem } from './ResultItem';
|
|
||||||
import styles from './CommandPalette.module.css';
|
|
||||||
|
|
||||||
interface ResultsListProps {
|
|
||||||
results: PaletteResult[];
|
|
||||||
isLoading: boolean;
|
|
||||||
onSelect: (result: PaletteResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResultsList({ results, isLoading, onSelect }: ResultsListProps) {
|
|
||||||
const { selectedIndex, query } = useCommandPalette();
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const items = listRef.current?.querySelectorAll('[data-palette-item]');
|
|
||||||
items?.[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
if (isLoading && results.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.results}>
|
|
||||||
<div className={styles.loadingDots}>
|
|
||||||
<div className={styles.loadingDot} />
|
|
||||||
<div className={styles.loadingDot} />
|
|
||||||
<div className={styles.loadingDot} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.results}>
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<svg className={styles.emptyIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<span className={styles.emptyText}>No results found</span>
|
|
||||||
<span className={styles.emptyHint}>
|
|
||||||
Try a different search or use filters like status:failed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group results by type
|
|
||||||
const executions = results.filter((r) => r.type === 'execution');
|
|
||||||
const applications = results.filter((r) => r.type === 'application');
|
|
||||||
const routes = results.filter((r) => r.type === 'route');
|
|
||||||
|
|
||||||
let globalIndex = 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.results} ref={listRef}>
|
|
||||||
{executions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className={styles.groupLabel}>Executions</div>
|
|
||||||
{executions.map((r) => {
|
|
||||||
const idx = globalIndex++;
|
|
||||||
return (
|
|
||||||
<ResultItem
|
|
||||||
key={r.id}
|
|
||||||
result={r}
|
|
||||||
selected={idx === selectedIndex}
|
|
||||||
query={query}
|
|
||||||
onClick={() => onSelect(r)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{applications.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className={styles.groupLabel}>Applications</div>
|
|
||||||
{applications.map((r) => {
|
|
||||||
const idx = globalIndex++;
|
|
||||||
return (
|
|
||||||
<ResultItem
|
|
||||||
key={r.id}
|
|
||||||
result={r}
|
|
||||||
selected={idx === selectedIndex}
|
|
||||||
query={query}
|
|
||||||
onClick={() => onSelect(r)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{routes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className={styles.groupLabel}>Routes</div>
|
|
||||||
{routes.map((r) => {
|
|
||||||
const idx = globalIndex++;
|
|
||||||
return (
|
|
||||||
<ResultItem
|
|
||||||
key={r.id}
|
|
||||||
result={r}
|
|
||||||
selected={idx === selectedIndex}
|
|
||||||
query={query}
|
|
||||||
onClick={() => onSelect(r)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useCommandPalette, type PaletteScope } from './use-command-palette';
|
|
||||||
import styles from './CommandPalette.module.css';
|
|
||||||
|
|
||||||
interface ScopeTabsProps {
|
|
||||||
executionCount: number;
|
|
||||||
applicationCount: number;
|
|
||||||
routeCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCOPES: { key: PaletteScope; label: string }[] = [
|
|
||||||
{ key: 'all', label: 'All' },
|
|
||||||
{ key: 'executions', label: 'Executions' },
|
|
||||||
{ key: 'applications', label: 'Applications' },
|
|
||||||
{ key: 'routes', label: 'Routes' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ScopeTabs({ executionCount, applicationCount, routeCount }: ScopeTabsProps) {
|
|
||||||
const { scope, setScope } = useCommandPalette();
|
|
||||||
|
|
||||||
function getCount(key: PaletteScope): number {
|
|
||||||
if (key === 'all') return executionCount + applicationCount + routeCount;
|
|
||||||
if (key === 'executions') return executionCount;
|
|
||||||
if (key === 'applications') return applicationCount;
|
|
||||||
if (key === 'routes') return routeCount;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.scopeTabs}>
|
|
||||||
{SCOPES.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.key}
|
|
||||||
className={scope === s.key ? styles.scopeTabActive : styles.scopeTab}
|
|
||||||
onClick={() => setScope(s.key)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
<span className={styles.scopeCount}>{getCount(s.key)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
export type PaletteScope = 'all' | 'executions' | 'applications' | 'routes';
|
|
||||||
|
|
||||||
export interface PaletteFilter {
|
|
||||||
key: 'status' | 'route' | 'agent' | 'processor';
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandPaletteState {
|
|
||||||
isOpen: boolean;
|
|
||||||
query: string;
|
|
||||||
scope: PaletteScope;
|
|
||||||
filters: PaletteFilter[];
|
|
||||||
selectedIndex: number;
|
|
||||||
|
|
||||||
open: () => void;
|
|
||||||
close: () => void;
|
|
||||||
setQuery: (q: string) => void;
|
|
||||||
setScope: (s: PaletteScope) => void;
|
|
||||||
addFilter: (f: PaletteFilter) => void;
|
|
||||||
removeLastFilter: () => void;
|
|
||||||
removeFilter: (index: number) => void;
|
|
||||||
setSelectedIndex: (i: number) => void;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCommandPalette = create<CommandPaletteState>((set) => ({
|
|
||||||
isOpen: false,
|
|
||||||
query: '',
|
|
||||||
scope: 'all',
|
|
||||||
filters: [],
|
|
||||||
selectedIndex: 0,
|
|
||||||
|
|
||||||
open: () => set({ isOpen: true }),
|
|
||||||
close: () => set({ isOpen: false, selectedIndex: 0 }),
|
|
||||||
setQuery: (q) => set({ query: q, selectedIndex: 0 }),
|
|
||||||
setScope: (s) => set({ scope: s, selectedIndex: 0 }),
|
|
||||||
addFilter: (f) =>
|
|
||||||
set((state) => ({
|
|
||||||
filters: [...state.filters.filter((x) => x.key !== f.key), f],
|
|
||||||
query: '',
|
|
||||||
selectedIndex: 0,
|
|
||||||
})),
|
|
||||||
removeLastFilter: () =>
|
|
||||||
set((state) => ({
|
|
||||||
filters: state.filters.slice(0, -1),
|
|
||||||
selectedIndex: 0,
|
|
||||||
})),
|
|
||||||
removeFilter: (index) =>
|
|
||||||
set((state) => ({
|
|
||||||
filters: state.filters.filter((_, i) => i !== index),
|
|
||||||
selectedIndex: 0,
|
|
||||||
})),
|
|
||||||
setSelectedIndex: (i) => set({ selectedIndex: i }),
|
|
||||||
reset: () => set({ query: '', scope: 'all', filters: [], selectedIndex: 0 }),
|
|
||||||
}));
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { api } from '../../api/client';
|
|
||||||
import type { ExecutionSummary, AgentInstance } from '../../api/types';
|
|
||||||
import { useCommandPalette, type PaletteScope } from './use-command-palette';
|
|
||||||
import { useDebouncedValue } from './utils';
|
|
||||||
|
|
||||||
export interface RouteInfo {
|
|
||||||
routeId: string;
|
|
||||||
agentIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaletteResult {
|
|
||||||
type: 'execution' | 'application' | 'route';
|
|
||||||
id: string;
|
|
||||||
data: ExecutionSummary | AgentInstance | RouteInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExecutionScope(scope: PaletteScope) {
|
|
||||||
return scope === 'all' || scope === 'executions';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isApplicationScope(scope: PaletteScope) {
|
|
||||||
return scope === 'all' || scope === 'applications';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRouteScope(scope: PaletteScope) {
|
|
||||||
return scope === 'all' || scope === 'routes';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePaletteSearch() {
|
|
||||||
const { query, scope, filters, isOpen } = useCommandPalette();
|
|
||||||
const debouncedQuery = useDebouncedValue(query, 300);
|
|
||||||
|
|
||||||
const statusFilter = filters.find((f) => f.key === 'status')?.value;
|
|
||||||
const routeFilter = filters.find((f) => f.key === 'route')?.value;
|
|
||||||
const agentFilter = filters.find((f) => f.key === 'agent')?.value;
|
|
||||||
const processorFilter = filters.find((f) => f.key === 'processor')?.value;
|
|
||||||
|
|
||||||
const executionsQuery = useQuery({
|
|
||||||
queryKey: ['palette', 'executions', debouncedQuery, statusFilter, routeFilter, agentFilter, processorFilter],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, error } = await api.POST('/search/executions', {
|
|
||||||
body: {
|
|
||||||
text: debouncedQuery || undefined,
|
|
||||||
status: statusFilter || undefined,
|
|
||||||
routeId: routeFilter || undefined,
|
|
||||||
agentId: agentFilter || undefined,
|
|
||||||
processorType: processorFilter || undefined,
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw new Error('Search failed');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
enabled: isOpen && isExecutionScope(scope),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentsQuery = useQuery({
|
|
||||||
queryKey: ['agents'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, error } = await api.GET('/agents', {
|
|
||||||
params: { query: {} },
|
|
||||||
});
|
|
||||||
if (error) throw new Error('Failed to load agents');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)),
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const executionResults: PaletteResult[] = (executionsQuery.data?.data ?? []).map((e) => ({
|
|
||||||
type: 'execution' as const,
|
|
||||||
id: e.executionId,
|
|
||||||
data: e,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filteredAgents = (agentsQuery.data ?? []).filter((a) => {
|
|
||||||
if (!debouncedQuery) return true;
|
|
||||||
const q = debouncedQuery.toLowerCase();
|
|
||||||
return a.id.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
const applicationResults: PaletteResult[] = filteredAgents.slice(0, 10).map((a) => ({
|
|
||||||
type: 'application' as const,
|
|
||||||
id: a.id,
|
|
||||||
data: a,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Derive unique routes from all agents
|
|
||||||
const routeMap = new Map<string, string[]>();
|
|
||||||
for (const agent of agentsQuery.data ?? []) {
|
|
||||||
for (const routeId of agent.routeIds ?? []) {
|
|
||||||
const existing = routeMap.get(routeId);
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.includes(agent.id)) existing.push(agent.id);
|
|
||||||
} else {
|
|
||||||
routeMap.set(routeId, [agent.id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allRoutes: RouteInfo[] = Array.from(routeMap.entries()).map(([routeId, agentIds]) => ({
|
|
||||||
routeId,
|
|
||||||
agentIds,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filteredRoutes = allRoutes.filter((r) => {
|
|
||||||
if (!debouncedQuery) return true;
|
|
||||||
const q = debouncedQuery.toLowerCase();
|
|
||||||
return r.routeId.toLowerCase().includes(q) || r.agentIds.some((a) => a.toLowerCase().includes(q));
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeResults: PaletteResult[] = filteredRoutes.slice(0, 10).map((r) => ({
|
|
||||||
type: 'route' as const,
|
|
||||||
id: r.routeId,
|
|
||||||
data: r,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let results: PaletteResult[] = [];
|
|
||||||
if (scope === 'all') results = [...executionResults, ...applicationResults, ...routeResults];
|
|
||||||
else if (scope === 'executions') results = executionResults;
|
|
||||||
else if (scope === 'applications') results = applicationResults;
|
|
||||||
else if (scope === 'routes') results = routeResults;
|
|
||||||
|
|
||||||
return {
|
|
||||||
results,
|
|
||||||
executionCount: executionsQuery.data?.total ?? 0,
|
|
||||||
applicationCount: filteredAgents.length,
|
|
||||||
routeCount: filteredRoutes.length,
|
|
||||||
isLoading: executionsQuery.isFetching || agentsQuery.isFetching,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import type { PaletteFilter } from './use-command-palette';
|
|
||||||
|
|
||||||
const FILTER_PREFIXES = ['status:', 'route:', 'agent:', 'processor:'] as const;
|
|
||||||
|
|
||||||
type FilterKey = PaletteFilter['key'];
|
|
||||||
|
|
||||||
const PREFIX_TO_KEY: Record<string, FilterKey> = {
|
|
||||||
'status:': 'status',
|
|
||||||
'route:': 'route',
|
|
||||||
'agent:': 'agent',
|
|
||||||
'processor:': 'processor',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseFilterPrefix(
|
|
||||||
input: string,
|
|
||||||
): { filter: PaletteFilter; remaining: string } | null {
|
|
||||||
for (const prefix of FILTER_PREFIXES) {
|
|
||||||
if (input.startsWith(prefix)) {
|
|
||||||
const value = input.slice(prefix.length).trim();
|
|
||||||
if (value && value.includes(' ')) {
|
|
||||||
const spaceIdx = value.indexOf(' ');
|
|
||||||
return {
|
|
||||||
filter: { key: PREFIX_TO_KEY[prefix], value: value.slice(0, spaceIdx) },
|
|
||||||
remaining: value.slice(spaceIdx + 1).trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkTrailingFilter(input: string): PaletteFilter | null {
|
|
||||||
for (const prefix of FILTER_PREFIXES) {
|
|
||||||
if (input.endsWith(' ') && input.trimEnd().length > prefix.length) {
|
|
||||||
const trimmed = input.trimEnd();
|
|
||||||
for (const p of FILTER_PREFIXES) {
|
|
||||||
const idx = trimmed.lastIndexOf(p);
|
|
||||||
if (idx !== -1 && idx === trimmed.length - p.length - (trimmed.length - trimmed.lastIndexOf(p) - p.length)) {
|
|
||||||
// This is getting complex, let's use a simpler approach
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Simple approach: check if last word matches prefix:value pattern
|
|
||||||
const words = input.trimEnd().split(/\s+/);
|
|
||||||
const lastWord = words[words.length - 1];
|
|
||||||
for (const prefix of FILTER_PREFIXES) {
|
|
||||||
if (lastWord.startsWith(prefix) && lastWord.length > prefix.length && input.endsWith(' ')) {
|
|
||||||
return {
|
|
||||||
key: PREFIX_TO_KEY[prefix],
|
|
||||||
value: lastWord.slice(prefix.length),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function highlightMatch(text: string, query: string): (string | { highlight: string })[] {
|
|
||||||
if (!query) return [text];
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
const qLower = query.toLowerCase();
|
|
||||||
const idx = lower.indexOf(qLower);
|
|
||||||
if (idx === -1) return [text];
|
|
||||||
return [
|
|
||||||
text.slice(0, idx),
|
|
||||||
{ highlight: text.slice(idx, idx + query.length) },
|
|
||||||
text.slice(idx + query.length),
|
|
||||||
].filter((s) => (typeof s === 'string' ? s.length > 0 : true));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
|
||||||
const [debounced, setDebounced] = useState(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRelativeTime(iso: string): string {
|
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
.layout {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 24px;
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Outlet } from 'react-router';
|
|
||||||
import { TopNav } from './TopNav';
|
|
||||||
import { AppSidebar } from './AppSidebar';
|
|
||||||
import { CommandPalette } from '../command-palette/CommandPalette';
|
|
||||||
import styles from './AppShell.module.css';
|
|
||||||
|
|
||||||
const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
|
|
||||||
|
|
||||||
export function AppShell() {
|
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
|
||||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
|
|
||||||
catch { return false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-collapse on small screens
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia('(max-width: 1024px)');
|
|
||||||
function handleChange(e: MediaQueryListEvent | MediaQueryList) {
|
|
||||||
if (e.matches) setCollapsed(true);
|
|
||||||
}
|
|
||||||
handleChange(mq);
|
|
||||||
mq.addEventListener('change', handleChange);
|
|
||||||
return () => mq.removeEventListener('change', handleChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = !prev;
|
|
||||||
try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
|
|
||||||
catch { /* ignore */ }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TopNav onToggleSidebar={toggleSidebar} />
|
|
||||||
<div className={styles.layout}>
|
|
||||||
<AppSidebar collapsed={collapsed} />
|
|
||||||
<main className={styles.main}>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<CommandPalette />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
/* ─── Sidebar Container ─── */
|
|
||||||
.sidebar {
|
|
||||||
width: 240px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-right: 1px solid var(--border-subtle);
|
|
||||||
height: calc(100vh - 56px);
|
|
||||||
position: sticky;
|
|
||||||
top: 56px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed {
|
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Search ─── */
|
|
||||||
.search {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .search {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput:focus {
|
|
||||||
border-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── App List ─── */
|
|
||||||
.appList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Section Divider ─── */
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
margin: 4px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .divider {
|
|
||||||
margin: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── App Item ─── */
|
|
||||||
.appItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.appItemActive {
|
|
||||||
background: var(--amber-glow);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .appItem {
|
|
||||||
padding: 8px 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Health Dot ─── */
|
|
||||||
.healthDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dotLive { background: var(--green); }
|
|
||||||
.dotStale { background: var(--amber); }
|
|
||||||
.dotDead { background: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ─── App Info (hidden when collapsed) ─── */
|
|
||||||
.appInfo {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .appInfo {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appName {
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appMeta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── All Item icon ─── */
|
|
||||||
.allIcon {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appItemActive .allIcon {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Bottom Section ─── */
|
|
||||||
.bottom {
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomItemActive {
|
|
||||||
color: var(--amber);
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .bottomItem {
|
|
||||||
padding: 8px 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomLabel {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .bottomLabel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomIcon {
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Admin Sub-Menu ─── */
|
|
||||||
.adminChevron {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.adminSubMenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.adminSubItem {
|
|
||||||
display: block;
|
|
||||||
padding: 6px 16px 6px 42px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.1s;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.adminSubItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.adminSubItemActive {
|
|
||||||
color: var(--amber);
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCollapsed .adminSubMenu {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.sidebar {
|
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .search {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .appInfo {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .appItem {
|
|
||||||
padding: 8px 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .divider {
|
|
||||||
margin: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .bottomItem {
|
|
||||||
padding: 8px 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .bottomLabel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .adminSubMenu {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { NavLink, useParams, useLocation } from 'react-router';
|
|
||||||
import { useAgents } from '../../api/queries/agents';
|
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
|
||||||
import type { AgentInstance } from '../../api/types';
|
|
||||||
import styles from './AppSidebar.module.css';
|
|
||||||
|
|
||||||
interface GroupInfo {
|
|
||||||
group: string;
|
|
||||||
agents: AgentInstance[];
|
|
||||||
liveCount: number;
|
|
||||||
staleCount: number;
|
|
||||||
deadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
|
||||||
if (g.liveCount > 0) return 'live';
|
|
||||||
if (g.staleCount > 0) return 'stale';
|
|
||||||
return 'dead';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppSidebarProps {
|
|
||||||
collapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
|
||||||
const { group: activeGroup } = useParams<{ group: string }>();
|
|
||||||
const { data: agents } = useAgents();
|
|
||||||
const { roles } = useAuthStore();
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
if (!agents) return [];
|
|
||||||
const map = new Map<string, GroupInfo>();
|
|
||||||
for (const agent of agents) {
|
|
||||||
const key = agent.group ?? 'default';
|
|
||||||
let entry = map.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
|
||||||
map.set(key, entry);
|
|
||||||
}
|
|
||||||
entry.agents.push(agent);
|
|
||||||
if (agent.status === 'LIVE') entry.liveCount++;
|
|
||||||
else if (agent.status === 'STALE') entry.staleCount++;
|
|
||||||
else entry.deadCount++;
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
|
||||||
}, [agents]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!filter) return groups;
|
|
||||||
const lower = filter.toLowerCase();
|
|
||||||
return groups.filter((g) => g.group.toLowerCase().includes(lower));
|
|
||||||
}, [groups, filter]);
|
|
||||||
|
|
||||||
const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={sidebarClass}>
|
|
||||||
{/* Search */}
|
|
||||||
<div className={styles.search}>
|
|
||||||
<input
|
|
||||||
className={styles.searchInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter apps..."
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App List */}
|
|
||||||
<div className={styles.appList}>
|
|
||||||
{/* All (unscoped) */}
|
|
||||||
<NavLink
|
|
||||||
to="/executions"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`${styles.appItem} ${isActive && !activeGroup ? styles.appItemActive : ''}`
|
|
||||||
}
|
|
||||||
title="All Applications"
|
|
||||||
>
|
|
||||||
<span className={styles.allIcon}>*</span>
|
|
||||||
<span className={styles.appInfo}>
|
|
||||||
<span className={styles.appName}>All</span>
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
<div className={styles.divider} />
|
|
||||||
|
|
||||||
{/* App entries */}
|
|
||||||
{filtered.map((g) => {
|
|
||||||
const status = healthStatus(g);
|
|
||||||
const isActive = activeGroup === g.group;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={g.group}
|
|
||||||
to={`/apps/${encodeURIComponent(g.group)}`}
|
|
||||||
className={`${styles.appItem} ${isActive ? styles.appItemActive : ''}`}
|
|
||||||
title={g.group}
|
|
||||||
>
|
|
||||||
<span className={`${styles.healthDot} ${styles[`dot${status.charAt(0).toUpperCase()}${status.slice(1)}`]}`} />
|
|
||||||
<span className={styles.appInfo}>
|
|
||||||
<span className={styles.appName}>{g.group}</span>
|
|
||||||
<span className={styles.appMeta}>
|
|
||||||
{g.agents.length} agent{g.agents.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom: Admin */}
|
|
||||||
{roles.includes('ADMIN') && (
|
|
||||||
<div className={styles.bottom}>
|
|
||||||
<AdminSubMenu collapsed={collapsed} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADMIN_LINKS = [
|
|
||||||
{ to: '/admin/database', label: 'Database' },
|
|
||||||
{ to: '/admin/opensearch', label: 'OpenSearch' },
|
|
||||||
{ to: '/admin/audit', label: 'Audit Log' },
|
|
||||||
{ to: '/admin/oidc', label: 'OIDC' },
|
|
||||||
{ to: '/admin/rbac', label: 'User Management' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) {
|
|
||||||
const location = useLocation();
|
|
||||||
const isAdminActive = location.pathname.startsWith('/admin');
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem('cameleer-admin-sidebar-open') === 'true';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
const next = !open;
|
|
||||||
setOpen(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem('cameleer-admin-sidebar-open', String(next));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.bottomItem} ${isAdminActive ? styles.bottomItemActive : ''}`}
|
|
||||||
onClick={toggle}
|
|
||||||
title="Admin"
|
|
||||||
>
|
|
||||||
<span className={styles.bottomIcon}>⚙</span>
|
|
||||||
<span className={styles.bottomLabel}>
|
|
||||||
Admin
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<span className={styles.adminChevron}>
|
|
||||||
{open ? '\u25BC' : '\u25B6'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && !sidebarCollapsed && (
|
|
||||||
<div className={styles.adminSubMenu}>
|
|
||||||
{ADMIN_LINKS.map((link) => (
|
|
||||||
<NavLink
|
|
||||||
key={link.to}
|
|
||||||
to={link.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`${styles.adminSubItem} ${isActive ? styles.adminSubItemActive : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
.topnav {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: var(--topnav-bg);
|
|
||||||
backdrop-filter: blur(20px) saturate(1.2);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
padding: 0 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 56px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--amber);
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo:hover { color: var(--amber); }
|
|
||||||
|
|
||||||
/* ─── Search Bar ─── */
|
|
||||||
.searchBar {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 480px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBar:hover {
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchPlaceholder {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchKbd {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Right Section ─── */
|
|
||||||
.navRight {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.utilLink {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 99px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.utilLink:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.utilLinkActive {
|
|
||||||
composes: utilLink;
|
|
||||||
color: var(--amber);
|
|
||||||
border-color: rgba(245, 158, 11, 0.3);
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.envBadge {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 99px;
|
|
||||||
background: var(--green-glow);
|
|
||||||
color: var(--green);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeToggle {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 6px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeToggle:hover {
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.userInfo {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoutBtn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoutBtn:hover {
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.searchBar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { NavLink } from 'react-router';
|
|
||||||
import { useThemeStore } from '../../theme/theme-store';
|
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
|
||||||
import { useCommandPalette } from '../command-palette/use-command-palette';
|
|
||||||
import styles from './TopNav.module.css';
|
|
||||||
|
|
||||||
interface TopNavProps {
|
|
||||||
onToggleSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopNav({ onToggleSidebar }: TopNavProps) {
|
|
||||||
const { theme, toggle } = useThemeStore();
|
|
||||||
const { username, logout } = useAuthStore();
|
|
||||||
const openPalette = useCommandPalette((s) => s.open);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={styles.topnav}>
|
|
||||||
<button className={styles.hamburger} onClick={onToggleSidebar} title="Toggle sidebar">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
||||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<NavLink to="/" className={styles.logo}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
|
||||||
<path d="M12 6v6l4 2" />
|
|
||||||
</svg>
|
|
||||||
cameleer3
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
{/* Visible search bar */}
|
|
||||||
<div className={styles.searchBar} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
|
|
||||||
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="M21 21l-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<span className={styles.searchPlaceholder}>Search executions, orders...</span>
|
|
||||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.navRight}>
|
|
||||||
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">
|
|
||||||
API
|
|
||||||
</NavLink>
|
|
||||||
<span className={styles.envBadge}>{import.meta.env.VITE_ENV_NAME || 'DEV'}</span>
|
|
||||||
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
|
||||||
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
|
||||||
</button>
|
|
||||||
{username && (
|
|
||||||
<span className={styles.userInfo}>
|
|
||||||
{username}
|
|
||||||
<button className={styles.logoutBtn} onClick={logout} title="Sign out">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
|
|
||||||
const COLORS = ['#3b82f6', '#f0b429', '#10b981', '#a855f7', '#f43f5e', '#22d3ee', '#ec4899'];
|
|
||||||
|
|
||||||
function hashColor(name: string) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return COLORS[Math.abs(hash) % COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppBadge({ name }: { name: string }) {
|
|
||||||
return (
|
|
||||||
<span className={styles.appBadge}>
|
|
||||||
<span className={styles.appDot} style={{ background: hashColor(name) }} />
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
|
|
||||||
function durationClass(ms: number) {
|
|
||||||
if (ms < 100) return styles.barFast;
|
|
||||||
if (ms < 1000) return styles.barMedium;
|
|
||||||
return styles.barSlow;
|
|
||||||
}
|
|
||||||
|
|
||||||
function durationColor(ms: number) {
|
|
||||||
if (ms < 100) return 'var(--green)';
|
|
||||||
if (ms < 1000) return 'var(--amber)';
|
|
||||||
return 'var(--rose)';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DurationBar({ duration }: { duration: number }) {
|
|
||||||
const widthPct = Math.min(100, (duration / 5000) * 100);
|
|
||||||
return (
|
|
||||||
<div className={styles.durationBar}>
|
|
||||||
<span className="mono" style={{ color: durationColor(duration) }}>
|
|
||||||
{duration.toLocaleString()}ms
|
|
||||||
</span>
|
|
||||||
<div className={styles.bar}>
|
|
||||||
<div
|
|
||||||
className={`${styles.barFill} ${durationClass(duration)}`}
|
|
||||||
style={{ width: `${widthPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
|
|
||||||
interface FilterChipProps {
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
accent?: 'green' | 'rose' | 'blue';
|
|
||||||
count?: number;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterChip({ label, active, accent, count, onClick }: FilterChipProps) {
|
|
||||||
const accentClass = accent ? styles[`chip${accent.charAt(0).toUpperCase()}${accent.slice(1)}`] : '';
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`${styles.chip} ${active ? styles.chipActive : ''} ${accentClass}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{accent && <span className={styles.chipDot} />}
|
|
||||||
{label}
|
|
||||||
{count !== undefined && <span className={styles.chipCount}>{count.toLocaleString()}</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
|
|
||||||
interface PaginationProps {
|
|
||||||
total: number;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
onChange: (offset: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Pagination({ total, offset, limit, onChange }: PaginationProps) {
|
|
||||||
const currentPage = Math.floor(offset / limit) + 1;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
||||||
|
|
||||||
if (totalPages <= 1) return null;
|
|
||||||
|
|
||||||
const pages: (number | '...')[] = [];
|
|
||||||
if (totalPages <= 7) {
|
|
||||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
|
||||||
} else {
|
|
||||||
pages.push(1);
|
|
||||||
if (currentPage > 3) pages.push('...');
|
|
||||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
|
||||||
pages.push(i);
|
|
||||||
}
|
|
||||||
if (currentPage < totalPages - 2) pages.push('...');
|
|
||||||
pages.push(totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<button
|
|
||||||
className={`${styles.pageBtn} ${currentPage === 1 ? styles.pageBtnDisabled : ''}`}
|
|
||||||
onClick={() => currentPage > 1 && onChange((currentPage - 2) * limit)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
{pages.map((p, i) =>
|
|
||||||
p === '...' ? (
|
|
||||||
<span key={`e${i}`} className={styles.pageEllipsis}>…</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
className={`${styles.pageBtn} ${p === currentPage ? styles.pageBtnActive : ''}`}
|
|
||||||
onClick={() => onChange((p - 1) * limit)}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className={`${styles.pageBtn} ${currentPage === totalPages ? styles.pageBtnDisabled : ''}`}
|
|
||||||
onClick={() => currentPage < totalPages && onChange(currentPage * limit)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface ResizableDividerProps {
|
|
||||||
/** Current panel width in pixels */
|
|
||||||
panelWidth: number;
|
|
||||||
/** Called with new width */
|
|
||||||
onResize: (width: number) => void;
|
|
||||||
/** Min panel width */
|
|
||||||
minWidth?: number;
|
|
||||||
/** Max panel width */
|
|
||||||
maxWidth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResizableDivider({
|
|
||||||
panelWidth,
|
|
||||||
onResize,
|
|
||||||
minWidth = 200,
|
|
||||||
maxWidth = 600,
|
|
||||||
}: ResizableDividerProps) {
|
|
||||||
const dragging = useRef(false);
|
|
||||||
const startX = useRef(0);
|
|
||||||
const startWidth = useRef(0);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragging.current = true;
|
|
||||||
startX.current = e.clientX;
|
|
||||||
startWidth.current = panelWidth;
|
|
||||||
document.body.style.cursor = 'col-resize';
|
|
||||||
document.body.style.userSelect = 'none';
|
|
||||||
}, [panelWidth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleMouseMove(e: MouseEvent) {
|
|
||||||
if (!dragging.current) return;
|
|
||||||
// Dragging left increases panel width (panel is on the right)
|
|
||||||
const delta = startX.current - e.clientX;
|
|
||||||
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth.current + delta));
|
|
||||||
onResize(newWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseUp() {
|
|
||||||
if (!dragging.current) return;
|
|
||||||
dragging.current = false;
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
document.body.style.userSelect = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [onResize, minWidth, maxWidth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
cursor: 'col-resize',
|
|
||||||
background: 'var(--border-subtle)',
|
|
||||||
flexShrink: 0,
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 5,
|
|
||||||
transition: 'background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--amber)'; }}
|
|
||||||
onMouseLeave={(e) => { if (!dragging.current) (e.currentTarget as HTMLElement).style.background = 'var(--border-subtle)'; }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
import { MiniChart } from '../charts/MiniChart';
|
|
||||||
|
|
||||||
const ACCENT_COLORS: Record<string, string> = {
|
|
||||||
amber: 'var(--amber)',
|
|
||||||
cyan: 'var(--cyan)',
|
|
||||||
rose: 'var(--rose)',
|
|
||||||
green: 'var(--green)',
|
|
||||||
blue: 'var(--blue)',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
accent: 'amber' | 'cyan' | 'rose' | 'green' | 'blue';
|
|
||||||
change?: string;
|
|
||||||
changeDirection?: 'up' | 'down' | 'neutral';
|
|
||||||
sparkData?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatCard({ label, value, accent, change, changeDirection = 'neutral', sparkData }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<div className={`${styles.statCard} ${styles[accent]}`}>
|
|
||||||
<div className={styles.statLabel}>{label}</div>
|
|
||||||
<div className={styles.statValue}>{value}</div>
|
|
||||||
{change && (
|
|
||||||
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
|
|
||||||
)}
|
|
||||||
{sparkData && sparkData.length >= 2 && (
|
|
||||||
<MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import styles from './shared.module.css';
|
|
||||||
|
|
||||||
const STATUS_MAP = {
|
|
||||||
COMPLETED: { className: styles.pillCompleted, label: 'Completed' },
|
|
||||||
FAILED: { className: styles.pillFailed, label: 'Failed' },
|
|
||||||
RUNNING: { className: styles.pillRunning, label: 'Running' },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function StatusPill({ status }: { status: string }) {
|
|
||||||
const info = STATUS_MAP[status as keyof typeof STATUS_MAP] ?? STATUS_MAP.COMPLETED;
|
|
||||||
return (
|
|
||||||
<span className={`${styles.statusPill} ${info.className}`}>
|
|
||||||
<span className={styles.statusDot} />
|
|
||||||
{info.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
/* ─── Status Pill ─── */
|
|
||||||
.statusPill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 99px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillCompleted { background: var(--green-glow); color: var(--green); }
|
|
||||||
.pillFailed { background: var(--rose-glow); color: var(--rose); }
|
|
||||||
.pillRunning { background: rgba(59, 130, 246, 0.12); color: var(--blue); }
|
|
||||||
.pillRunning .statusDot { animation: livePulse 1.5s ease-in-out infinite; }
|
|
||||||
|
|
||||||
/* ─── Duration Bar ─── */
|
|
||||||
.durationBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 60px;
|
|
||||||
height: 4px;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barFill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barFast { background: var(--green); }
|
|
||||||
.barMedium { background: var(--amber); }
|
|
||||||
.barSlow { background: var(--rose); }
|
|
||||||
|
|
||||||
/* ─── Stat Card ─── */
|
|
||||||
.statCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 16px 20px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statCard:hover { border-color: var(--border); }
|
|
||||||
|
|
||||||
.statCard::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
|
|
||||||
.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
|
|
||||||
.rose::before { background: linear-gradient(90deg, var(--rose), transparent); }
|
|
||||||
.green::before { background: linear-gradient(90deg, var(--green), transparent); }
|
|
||||||
.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amber .statValue { color: var(--amber); }
|
|
||||||
.cyan .statValue { color: var(--cyan); }
|
|
||||||
.rose .statValue { color: var(--rose); }
|
|
||||||
.green .statValue { color: var(--green); }
|
|
||||||
.blue .statValue { color: var(--blue); }
|
|
||||||
|
|
||||||
.statChange {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.up { color: var(--rose); }
|
|
||||||
.down { color: var(--green); }
|
|
||||||
.neutral { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ─── App Badge ─── */
|
|
||||||
.appBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.appDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Filter Chip ─── */
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 99px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
|
||||||
|
|
||||||
.chipActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
|
||||||
.chipActive.chipGreen { background: var(--green-glow); border-color: rgba(16, 185, 129, 0.3); color: var(--green); }
|
|
||||||
.chipActive.chipRose { background: var(--rose-glow); border-color: rgba(244, 63, 94, 0.3); color: var(--rose); }
|
|
||||||
.chipActive.chipBlue { background: rgba(59, 130, 246, 0.12); border-color: rgba(59, 130, 246, 0.3); color: var(--blue); }
|
|
||||||
|
|
||||||
.chipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipCount {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Pagination ─── */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:hover:not(:disabled) { border-color: var(--border); background: var(--bg-raised); }
|
|
||||||
.pageBtnActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
|
||||||
.pageBtnDisabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.pageEllipsis {
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 0 4px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
||||||
import type { ExecutionDetail, ProcessorNode } from '../api/types';
|
|
||||||
|
|
||||||
export interface IterationData {
|
|
||||||
count: number;
|
|
||||||
current: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayState {
|
|
||||||
isActive: boolean;
|
|
||||||
toggle: () => void;
|
|
||||||
executedNodes: Set<string>;
|
|
||||||
executedEdges: Set<string>;
|
|
||||||
durations: Map<string, number>;
|
|
||||||
sequences: Map<string, number>;
|
|
||||||
statuses: Map<string, string>;
|
|
||||||
iterationData: Map<string, IterationData>;
|
|
||||||
selectedNodeId: string | null;
|
|
||||||
selectNode: (nodeId: string | null) => void;
|
|
||||||
setIteration: (nodeId: string, iteration: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walk the processor tree and collect execution data keyed by diagramNodeId */
|
|
||||||
function collectProcessorData(
|
|
||||||
processors: ProcessorNode[],
|
|
||||||
executedNodes: Set<string>,
|
|
||||||
durations: Map<string, number>,
|
|
||||||
sequences: Map<string, number>,
|
|
||||||
statuses: Map<string, string>,
|
|
||||||
counter: { seq: number },
|
|
||||||
) {
|
|
||||||
for (const proc of processors) {
|
|
||||||
const nodeId = proc.diagramNodeId;
|
|
||||||
if (nodeId) {
|
|
||||||
executedNodes.add(nodeId);
|
|
||||||
durations.set(nodeId, proc.durationMs ?? 0);
|
|
||||||
sequences.set(nodeId, ++counter.seq);
|
|
||||||
if (proc.status) statuses.set(nodeId, proc.status);
|
|
||||||
}
|
|
||||||
if (proc.children && proc.children.length > 0) {
|
|
||||||
collectProcessorData(proc.children, executedNodes, durations, sequences, statuses, counter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Determine which edges are executed (both source and target are executed) */
|
|
||||||
function computeExecutedEdges(
|
|
||||||
executedNodes: Set<string>,
|
|
||||||
edges: Array<{ sourceId?: string; targetId?: string }>,
|
|
||||||
): Set<string> {
|
|
||||||
const result = new Set<string>();
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (edge.sourceId && edge.targetId
|
|
||||||
&& executedNodes.has(edge.sourceId) && executedNodes.has(edge.targetId)) {
|
|
||||||
result.add(`${edge.sourceId}->${edge.targetId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExecutionOverlay(
|
|
||||||
execution: ExecutionDetail | null | undefined,
|
|
||||||
edges: Array<{ sourceId?: string; targetId?: string }> = [],
|
|
||||||
): OverlayState {
|
|
||||||
const [isActive, setIsActive] = useState(!!execution);
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
||||||
const [iterations, setIterations] = useState<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
// Activate overlay when an execution is loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (execution) setIsActive(true);
|
|
||||||
}, [execution]);
|
|
||||||
|
|
||||||
const { executedNodes, durations, sequences, statuses, iterationData } = useMemo(() => {
|
|
||||||
const en = new Set<string>();
|
|
||||||
const dur = new Map<string, number>();
|
|
||||||
const seq = new Map<string, number>();
|
|
||||||
const st = new Map<string, string>();
|
|
||||||
const iter = new Map<string, IterationData>();
|
|
||||||
|
|
||||||
if (!execution?.processors) {
|
|
||||||
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
|
|
||||||
}
|
|
||||||
|
|
||||||
collectProcessorData(execution.processors, en, dur, seq, st, { seq: 0 });
|
|
||||||
|
|
||||||
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
|
|
||||||
}, [execution]);
|
|
||||||
|
|
||||||
const executedEdges = useMemo(
|
|
||||||
() => computeExecutedEdges(executedNodes, edges),
|
|
||||||
[executedNodes, edges],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => setIsActive((v) => !v), []);
|
|
||||||
const selectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []);
|
|
||||||
const setIteration = useCallback((nodeId: string, iteration: number) => {
|
|
||||||
setIterations((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(nodeId, iteration);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard shortcut: E to toggle overlay
|
|
||||||
useEffect(() => {
|
|
||||||
function handleKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'e' || e.key === 'E') {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
|
||||||
e.preventDefault();
|
|
||||||
setIsActive((v) => !v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handleKey);
|
|
||||||
return () => window.removeEventListener('keydown', handleKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isActive,
|
|
||||||
toggle,
|
|
||||||
executedNodes,
|
|
||||||
executedEdges,
|
|
||||||
durations,
|
|
||||||
sequences,
|
|
||||||
statuses,
|
|
||||||
iterationData: new Map([...iterationData].map(([k, v]) => {
|
|
||||||
const current = iterations.get(k) ?? v.current;
|
|
||||||
return [k, { ...v, current }];
|
|
||||||
})),
|
|
||||||
selectedNodeId,
|
|
||||||
selectNode,
|
|
||||||
setIteration,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
18
ui/src/index.css
Normal file
18
ui/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import '@cameleer/design-system/style.css';
|
||||||
|
import './index.css';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ThemeProvider } from './theme/ThemeProvider';
|
import { ThemeProvider } from '@cameleer/design-system';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import './theme/fonts.css';
|
|
||||||
import './theme/tokens.css';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
93
ui/src/pages/AgentHealth/AgentHealth.tsx
Normal file
93
ui/src/pages/AgentHealth/AgentHealth.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, MonoText,
|
||||||
|
GroupCard, EventFeed,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
|
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||||
|
|
||||||
|
export default function AgentHealth() {
|
||||||
|
const { appId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: agents } = useAgents(undefined, appId);
|
||||||
|
const { data: catalog } = useRouteCatalog();
|
||||||
|
const { data: events } = useAgentEvents(appId);
|
||||||
|
|
||||||
|
const agentsByApp = useMemo(() => {
|
||||||
|
const map: Record<string, any[]> = {};
|
||||||
|
(agents || []).forEach((a: any) => {
|
||||||
|
const g = a.group;
|
||||||
|
if (!map[g]) map[g] = [];
|
||||||
|
map[g].push(a);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const totalAgents = agents?.length ?? 0;
|
||||||
|
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||||
|
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
|
||||||
|
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
|
||||||
|
|
||||||
|
const feedEvents = useMemo(() =>
|
||||||
|
(events || []).map((e: any) => ({
|
||||||
|
id: String(e.id),
|
||||||
|
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
||||||
|
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
||||||
|
: e.eventType === 'RECOVERED' ? 'success' as const
|
||||||
|
: 'running' as const,
|
||||||
|
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
||||||
|
timestamp: new Date(e.timestamp),
|
||||||
|
})),
|
||||||
|
[events],
|
||||||
|
);
|
||||||
|
|
||||||
|
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<StatCard label="Total Agents" value={totalAgents} />
|
||||||
|
<StatCard label="Live" value={liveCount} accent="success" />
|
||||||
|
<StatCard label="Stale" value={staleCount} accent="warning" />
|
||||||
|
<StatCard label="Dead" value={deadCount} accent="error" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
{Object.entries(apps).map(([group, groupAgents]) => (
|
||||||
|
<GroupCard
|
||||||
|
key={group}
|
||||||
|
title={group}
|
||||||
|
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />}
|
||||||
|
accent={
|
||||||
|
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||||
|
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||||
|
: 'success'
|
||||||
|
}
|
||||||
|
onClick={() => navigate(`/agents/${group}`)}
|
||||||
|
>
|
||||||
|
{(groupAgents || []).map((agent: any) => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0', cursor: 'pointer' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }}
|
||||||
|
>
|
||||||
|
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||||
|
<MonoText size="sm">{agent.name}</MonoText>
|
||||||
|
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
|
||||||
|
{agent.tps > 0 && <span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>{agent.tps.toFixed(1)} tps</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GroupCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedEvents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ marginBottom: '0.75rem' }}>Event Log</h3>
|
||||||
|
<EventFeed events={feedEvents} maxItems={100} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
ui/src/pages/AgentInstance/AgentInstance.tsx
Normal file
127
ui/src/pages/AgentInstance/AgentInstance.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, MonoText, Card,
|
||||||
|
LineChart, AreaChart, EventFeed, Breadcrumb, Spinner,
|
||||||
|
SectionHeader, CodeBlock,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
|
import { useStatsTimeseries } from '../../api/queries/executions';
|
||||||
|
import { useGlobalFilters } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
export default function AgentInstance() {
|
||||||
|
const { appId, instanceId } = useParams();
|
||||||
|
const { timeRange } = useGlobalFilters();
|
||||||
|
const timeFrom = timeRange.start.toISOString();
|
||||||
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
|
const { data: agents, isLoading } = useAgents(undefined, appId);
|
||||||
|
const { data: events } = useAgentEvents(appId, instanceId);
|
||||||
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||||
|
|
||||||
|
const agent = useMemo(() =>
|
||||||
|
(agents || []).find((a: any) => a.id === instanceId),
|
||||||
|
[agents, instanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b: any) => ({
|
||||||
|
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
throughput: b.totalCount,
|
||||||
|
latency: b.avgDurationMs,
|
||||||
|
errors: b.failedCount,
|
||||||
|
})),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const feedEvents = useMemo(() =>
|
||||||
|
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
|
||||||
|
id: String(e.id),
|
||||||
|
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
||||||
|
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
||||||
|
: e.eventType === 'RECOVERED' ? 'success' as const
|
||||||
|
: 'running' as const,
|
||||||
|
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
||||||
|
timestamp: new Date(e.timestamp),
|
||||||
|
})),
|
||||||
|
[events, instanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner size="lg" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={[
|
||||||
|
{ label: 'Agents', href: '/agents' },
|
||||||
|
{ label: appId || '', href: `/agents/${appId}` },
|
||||||
|
{ label: agent?.name || instanceId || '' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
{agent && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
|
||||||
|
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||||
|
<h2>{agent.name}</h2>
|
||||||
|
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<StatCard label="TPS" value={agent.tps?.toFixed(1) ?? '0'} />
|
||||||
|
<StatCard label="Error Rate" value={agent.errorRate ? `${(agent.errorRate * 100).toFixed(1)}%` : '0%'} accent={agent.errorRate > 0.05 ? 'error' : undefined} />
|
||||||
|
<StatCard label="Active Routes" value={`${agent.activeRoutes ?? 0}/${agent.totalRoutes ?? 0}`} />
|
||||||
|
<StatCard label="Uptime" value={formatUptime(agent.uptimeSeconds ?? 0)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Routes</SectionHeader>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||||
|
{(agent.routeIds || []).map((r: string) => (
|
||||||
|
<Badge key={r} label={r} color="auto" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader>Performance</SectionHeader>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
|
||||||
|
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedEvents.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader>Events</SectionHeader>
|
||||||
|
<EventFeed events={feedEvents} maxItems={50} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agent && (
|
||||||
|
<>
|
||||||
|
<SectionHeader>Agent Info</SectionHeader>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<CodeBlock content={JSON.stringify({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
group: agent.group,
|
||||||
|
registeredAt: agent.registeredAt,
|
||||||
|
lastHeartbeat: agent.lastHeartbeat,
|
||||||
|
routeIds: agent.routeIds,
|
||||||
|
}, null, 2)} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
|
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
||||||
|
}
|
||||||
131
ui/src/pages/Dashboard/Dashboard.tsx
Normal file
131
ui/src/pages/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, MonoText, Sparkline,
|
||||||
|
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||||
|
import { useGlobalFilters } from '@cameleer/design-system';
|
||||||
|
import type { ExecutionSummary } from '../../api/types';
|
||||||
|
|
||||||
|
interface Row extends ExecutionSummary { id: string }
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { appId, routeId } = useParams();
|
||||||
|
const { timeRange } = useGlobalFilters();
|
||||||
|
const timeFrom = timeRange.start.toISOString();
|
||||||
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [detailTab, setDetailTab] = useState('overview');
|
||||||
|
const [processorIdx, setProcessorIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||||
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||||
|
const { data: searchResult } = useSearchExecutions({
|
||||||
|
timeFrom, timeTo,
|
||||||
|
routeId: routeId || undefined,
|
||||||
|
group: appId || undefined,
|
||||||
|
page: 0, size: 50,
|
||||||
|
}, true);
|
||||||
|
const { data: detail } = useExecutionDetail(selectedId);
|
||||||
|
const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx);
|
||||||
|
|
||||||
|
const rows: Row[] = useMemo(() =>
|
||||||
|
(searchResult?.items || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||||
|
[searchResult],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sparklineData = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: Column<Row>[] = [
|
||||||
|
{
|
||||||
|
key: 'status', header: 'Status', width: '80px',
|
||||||
|
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
|
||||||
|
},
|
||||||
|
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||||
|
{ key: 'groupName', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||||
|
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
|
||||||
|
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
|
||||||
|
{
|
||||||
|
key: 'durationMs', header: 'Duration', sortable: true,
|
||||||
|
render: (v) => `${v}ms`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const detailTabs = detail ? [
|
||||||
|
{
|
||||||
|
label: 'Overview', value: 'overview',
|
||||||
|
content: (
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem', padding: '1rem' }}>
|
||||||
|
<div><strong>Execution ID:</strong> <MonoText size="sm">{detail.executionId}</MonoText></div>
|
||||||
|
<div><strong>Status:</strong> <Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} /></div>
|
||||||
|
<div><strong>Route:</strong> {detail.routeId}</div>
|
||||||
|
<div><strong>Duration:</strong> {detail.durationMs}ms</div>
|
||||||
|
{detail.errorMessage && <div><strong>Error:</strong> {detail.errorMessage}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Processors', value: 'processors',
|
||||||
|
content: detail.children ? (
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={flattenProcessors(detail.children)}
|
||||||
|
totalMs={detail.durationMs}
|
||||||
|
onProcessorClick={(_p, i) => setProcessorIdx(i)}
|
||||||
|
selectedIndex={processorIdx ?? undefined}
|
||||||
|
/>
|
||||||
|
) : <div style={{ padding: '1rem' }}>No processor data</div>,
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<StatCard label="Total Exchanges" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
|
||||||
|
<StatCard label="Failed" value={stats?.failedCount ?? 0} accent="error" />
|
||||||
|
<StatCard label="Avg Duration" value={`${stats?.avgDurationMs ?? 0}ms`} />
|
||||||
|
<StatCard label="P99 Duration" value={`${stats?.p99DurationMs ?? 0}ms`} accent="warning" />
|
||||||
|
<StatCard label="Active" value={stats?.activeCount ?? 0} accent="running" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => { setSelectedId(row.id); setProcessorIdx(null); }}
|
||||||
|
selectedId={selectedId ?? undefined}
|
||||||
|
sortable
|
||||||
|
pageSize={25}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailPanel
|
||||||
|
open={!!selectedId}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
title={selectedId ? `Exchange ${selectedId.slice(0, 12)}...` : ''}
|
||||||
|
tabs={detailTabs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProcessors(nodes: any[]): any[] {
|
||||||
|
const result: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
function walk(node: any) {
|
||||||
|
result.push({
|
||||||
|
name: node.processorId || node.processorType,
|
||||||
|
type: node.processorType,
|
||||||
|
durationMs: node.durationMs ?? 0,
|
||||||
|
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
||||||
|
startMs: offset,
|
||||||
|
});
|
||||||
|
offset += node.durationMs ?? 0;
|
||||||
|
if (node.children) node.children.forEach(walk);
|
||||||
|
}
|
||||||
|
nodes.forEach(walk);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
131
ui/src/pages/ExchangeDetail/ExchangeDetail.tsx
Normal file
131
ui/src/pages/ExchangeDetail/ExchangeDetail.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import {
|
||||||
|
Card, Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||||
|
ProcessorTimeline, Breadcrumb, Spinner,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||||
|
|
||||||
|
export default function ExchangeDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
|
||||||
|
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
|
||||||
|
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
|
||||||
|
|
||||||
|
const processors = useMemo(() => {
|
||||||
|
if (!detail?.children) return [];
|
||||||
|
const result: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
function walk(node: any) {
|
||||||
|
result.push({
|
||||||
|
name: node.processorId || node.processorType,
|
||||||
|
type: node.processorType,
|
||||||
|
durationMs: node.durationMs ?? 0,
|
||||||
|
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
||||||
|
startMs: offset,
|
||||||
|
});
|
||||||
|
offset += node.durationMs ?? 0;
|
||||||
|
if (node.children) node.children.forEach(walk);
|
||||||
|
}
|
||||||
|
detail.children.forEach(walk);
|
||||||
|
return result;
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||||
|
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={[
|
||||||
|
{ label: 'Dashboard', href: '/apps' },
|
||||||
|
{ label: detail.groupName || 'App', href: `/apps/${detail.groupName}` },
|
||||||
|
{ label: id?.slice(0, 12) || '' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', margin: '1.5rem 0' }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Status</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
|
||||||
|
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
||||||
|
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Duration</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 600 }}>{detail.durationMs}ms</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Route</div>
|
||||||
|
<MonoText>{detail.routeId}</MonoText>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Application</div>
|
||||||
|
<Badge label={detail.groupName || 'unknown'} color="auto" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.errorMessage && (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<InfoCallout variant="error">
|
||||||
|
{detail.errorMessage}
|
||||||
|
</InfoCallout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: '0.75rem' }}>Processor Timeline</h3>
|
||||||
|
{processors.length > 0 ? (
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={detail.durationMs}
|
||||||
|
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
|
||||||
|
selectedIndex={selectedProcessor ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InfoCallout>No processor data available</InfoCallout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && (
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.75rem' }}>Exchange Snapshot</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>Input Body</h4>
|
||||||
|
<CodeBlock content={String(snapshot.inputBody ?? 'null')} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>Output Body</h4>
|
||||||
|
<CodeBlock content={String(snapshot.outputBody ?? 'null')} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>Input Headers</h4>
|
||||||
|
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<h4 style={{ marginBottom: '0.5rem' }}>Output Headers</h4>
|
||||||
|
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
ui/src/pages/Routes/RoutesMetrics.tsx
Normal file
105
ui/src/pages/Routes/RoutesMetrics.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
StatCard, Sparkline, MonoText, Badge,
|
||||||
|
DataTable, AreaChart, LineChart, BarChart,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||||
|
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||||
|
import { useGlobalFilters } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
interface RouteRow {
|
||||||
|
id: string;
|
||||||
|
routeId: string;
|
||||||
|
appId: string;
|
||||||
|
exchangeCount: number;
|
||||||
|
successRate: number;
|
||||||
|
avgDurationMs: number;
|
||||||
|
p99DurationMs: number;
|
||||||
|
errorRate: number;
|
||||||
|
throughputPerSec: number;
|
||||||
|
sparkline: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoutesMetrics() {
|
||||||
|
const { appId, routeId } = useParams();
|
||||||
|
const { timeRange } = useGlobalFilters();
|
||||||
|
const timeFrom = timeRange.start.toISOString();
|
||||||
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
|
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||||
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||||
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||||
|
|
||||||
|
const rows: RouteRow[] = useMemo(() =>
|
||||||
|
(metrics || []).map((m: any) => ({
|
||||||
|
id: `${m.appId}/${m.routeId}`,
|
||||||
|
...m,
|
||||||
|
})),
|
||||||
|
[metrics],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sparklineData = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b: any) => ({
|
||||||
|
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
throughput: b.totalCount,
|
||||||
|
latency: b.avgDurationMs,
|
||||||
|
errors: b.failedCount,
|
||||||
|
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||||
|
})),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: Column<RouteRow>[] = [
|
||||||
|
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||||
|
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||||
|
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'successRate', header: 'Success', sortable: true,
|
||||||
|
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||||
|
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||||
|
{
|
||||||
|
key: 'errorRate', header: 'Error Rate', sortable: true,
|
||||||
|
render: (v) => <span style={{ color: (v as number) > 0.05 ? 'var(--error)' : undefined }}>{((v as number) * 100).toFixed(1)}%</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sparkline', header: 'Trend', width: '80px',
|
||||||
|
render: (v) => <Sparkline data={v as number[]} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<StatCard label="Total Throughput" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
|
||||||
|
<StatCard label="Error Rate" value={stats?.totalCount ? `${(((stats.failedCount ?? 0) / stats.totalCount) * 100).toFixed(1)}%` : '0%'} accent="error" />
|
||||||
|
<StatCard label="P99 Latency" value={`${stats?.p99DurationMs ?? 0}ms`} accent="warning" />
|
||||||
|
<StatCard label="Success Rate" value={stats?.totalCount ? `${(((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100).toFixed(1)}%` : '100%'} accent="success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
sortable
|
||||||
|
pageSize={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
|
||||||
|
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
|
||||||
|
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
|
||||||
|
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
||||||
|
<AreaChart series={[{ label: 'Success Rate', data: chartData.map((d: any, i: number) => ({ x: i, y: d.successRate })) }]} height={200} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
/* ─── Filter Bar ─── */
|
|
||||||
.filterBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterGroup {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterGroupGrow {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterSelect {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Table Area ─── */
|
|
||||||
.tableArea {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thTimestamp {
|
|
||||||
width: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thResult {
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 8px 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Event Rows ─── */
|
|
||||||
.eventRow {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventRow:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventRowExpanded {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellTimestamp {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellUser {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellTarget {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Badges ─── */
|
|
||||||
.categoryBadge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultBadge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultSuccess {
|
|
||||||
background: rgba(16, 185, 129, 0.12);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultFailure {
|
|
||||||
background: rgba(244, 63, 94, 0.12);
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Expanded Detail Row ─── */
|
|
||||||
.detailRow td {
|
|
||||||
padding: 0 14px 14px;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailMeta {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailField {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailValue {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailJson {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Pagination ─── */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn {
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:hover:not(:disabled) {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageInfo {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Empty State ─── */
|
|
||||||
.emptyState {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 16px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.filterBar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterGroupGrow {
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellTarget {
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +1,59 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
|
||||||
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import layout from '../../styles/AdminLayout.module.css';
|
import { useAuditLog } from '../../api/queries/admin/audit';
|
||||||
import styles from './AuditLogPage.module.css';
|
|
||||||
|
|
||||||
function defaultFrom(): string {
|
export default function AuditLogPage() {
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - 7);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultTo(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuditLogPage() {
|
|
||||||
const roles = useAuthStore((s) => s.roles);
|
|
||||||
|
|
||||||
if (!roles.includes('ADMIN')) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.accessDenied}>
|
|
||||||
Access Denied -- this page requires the ADMIN role.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AuditLogContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuditLogContent() {
|
|
||||||
const [from, setFrom] = useState(defaultFrom);
|
|
||||||
const [to, setTo] = useState(defaultTo);
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [category, setCategory] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [expandedRow, setExpandedRow] = useState<number | null>(null);
|
|
||||||
const pageSize = 25;
|
|
||||||
|
|
||||||
const params: AuditLogParams = {
|
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
|
||||||
from: from || undefined,
|
|
||||||
to: to || undefined,
|
|
||||||
username: username || undefined,
|
|
||||||
category: category || undefined,
|
|
||||||
search: search || undefined,
|
|
||||||
page,
|
|
||||||
size: pageSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
const audit = useAuditLog(params);
|
const columns: Column<any>[] = [
|
||||||
const data = audit.data;
|
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
||||||
const totalPages = data?.totalPages ?? 0;
|
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||||
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
|
{ key: 'action', header: 'Action' },
|
||||||
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
|
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||||
|
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
|
||||||
|
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = useMemo(() =>
|
||||||
|
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={layout.page}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className={layout.panelHeader}>
|
|
||||||
<div>
|
<div>
|
||||||
<div className={layout.panelTitle}>Audit Log</div>
|
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
||||||
<div className={layout.panelSubtitle}>
|
|
||||||
{data
|
|
||||||
? `${data.totalCount.toLocaleString()} events`
|
|
||||||
: 'Loading...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter bar */}
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
<div className={styles.filterBar}>
|
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<div className={styles.filterGroup}>
|
<Select
|
||||||
<label className={styles.filterLabel}>From</label>
|
options={[
|
||||||
<input
|
{ value: '', label: 'All Categories' },
|
||||||
type="date"
|
{ value: 'AUTH', label: 'Auth' },
|
||||||
className={styles.filterInput}
|
{ value: 'CONFIG', label: 'Config' },
|
||||||
value={from}
|
{ value: 'RBAC', label: 'RBAC' },
|
||||||
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
|
{ value: 'INFRA', label: 'Infra' },
|
||||||
/>
|
]}
|
||||||
</div>
|
|
||||||
<div className={styles.filterGroup}>
|
|
||||||
<label className={styles.filterLabel}>To</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={styles.filterInput}
|
|
||||||
value={to}
|
|
||||||
onChange={(e) => { setTo(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.filterGroup}>
|
|
||||||
<label className={styles.filterLabel}>User</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={styles.filterInput}
|
|
||||||
placeholder="Username..."
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.filterGroup}>
|
|
||||||
<label className={styles.filterLabel}>Category</label>
|
|
||||||
<select
|
|
||||||
className={styles.filterSelect}
|
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
>
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="INFRA">INFRA</option>
|
|
||||||
<option value="AUTH">AUTH</option>
|
|
||||||
<option value="USER_MGMT">USER_MGMT</option>
|
|
||||||
<option value="CONFIG">CONFIG</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.filterGroup} ${styles.filterGroupGrow}`}>
|
|
||||||
<label className={styles.filterLabel}>Search</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={styles.filterInput}
|
|
||||||
placeholder="Search actions, targets..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table area */}
|
<DataTable
|
||||||
<div className={styles.tableArea}>
|
columns={columns}
|
||||||
{audit.isLoading ? (
|
data={rows}
|
||||||
<div className={layout.loading}>Loading...</div>
|
sortable
|
||||||
) : !data || data.items.length === 0 ? (
|
pageSize={25}
|
||||||
<div className={styles.emptyState}>
|
expandedContent={(row) => (
|
||||||
No audit events found for the selected filters.
|
<div style={{ padding: '0.75rem' }}>
|
||||||
|
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className={styles.thTimestamp}>Timestamp</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Target</th>
|
|
||||||
<th className={styles.thResult}>Result</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((event) => (
|
|
||||||
<EventRow
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
isExpanded={expandedRow === event.id}
|
|
||||||
onToggle={() =>
|
|
||||||
setExpandedRow((prev) => (prev === event.id ? null : event.id))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{data && data.totalCount > 0 && (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.pageBtn}
|
|
||||||
disabled={page === 0}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className={styles.pageInfo}>
|
|
||||||
{showingFrom}--{showingTo} of {data.totalCount.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.pageBtn}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
|
||||||
event,
|
|
||||||
isExpanded,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
event: {
|
|
||||||
id: number;
|
|
||||||
timestamp: string;
|
|
||||||
username: string;
|
|
||||||
category: string;
|
|
||||||
action: string;
|
|
||||||
target: string;
|
|
||||||
result: string;
|
|
||||||
detail: Record<string, unknown>;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
};
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr
|
|
||||||
className={`${styles.eventRow} ${isExpanded ? styles.eventRowExpanded : ''}`}
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<td className={styles.cellTimestamp}>{formatTimestamp(event.timestamp)}</td>
|
|
||||||
<td className={styles.cellUser}>{event.username}</td>
|
|
||||||
<td>
|
|
||||||
<span className={styles.categoryBadge}>{event.category}</span>
|
|
||||||
</td>
|
|
||||||
<td>{event.action}</td>
|
|
||||||
<td className={styles.cellTarget}>{event.target}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`${styles.resultBadge} ${
|
|
||||||
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{event.result}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isExpanded && (
|
|
||||||
<tr className={styles.detailRow}>
|
|
||||||
<td colSpan={6}>
|
|
||||||
<div className={styles.detailContent}>
|
|
||||||
<div className={styles.detailMeta}>
|
|
||||||
<div className={styles.detailField}>
|
|
||||||
<span className={styles.detailLabel}>IP Address</span>
|
|
||||||
<span className={styles.detailValue}>{event.ipAddress}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.detailField}>
|
|
||||||
<span className={styles.detailLabel}>User Agent</span>
|
|
||||||
<span className={styles.detailValue}>{event.userAgent}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{event.detail && Object.keys(event.detail).length > 0 && (
|
|
||||||
<pre className={styles.detailJson}>
|
|
||||||
{JSON.stringify(event.detail, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
/* ─── Meta ─── */
|
|
||||||
.metaItem {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Progress Bar ─── */
|
|
||||||
.progressContainer {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressLabel {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressPct {
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressBar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressFill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Metrics Grid ─── */
|
|
||||||
.metricsGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricValue {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tables ─── */
|
|
||||||
.tableWrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queryCell {
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowWarning {
|
|
||||||
background: rgba(234, 179, 8, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.killBtn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--rose-dim);
|
|
||||||
color: var(--rose);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.killBtn:hover {
|
|
||||||
background: var(--rose-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Maintenance ─── */
|
|
||||||
.maintenanceGrid {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.maintenanceBtn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Thresholds ─── */
|
|
||||||
.thresholdGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdField {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdInput {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdInput:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdActions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
background: var(--amber);
|
|
||||||
color: #0a0e17;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:hover {
|
|
||||||
background: var(--amber-hover);
|
|
||||||
border-color: var(--amber-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.successMsg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.metricsGrid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,437 +1,67 @@
|
|||||||
import { useState } from 'react';
|
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import { StatusBadge } from '../../components/admin/StatusBadge';
|
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
|
||||||
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
|
||||||
import {
|
|
||||||
useDatabaseStatus,
|
|
||||||
useDatabasePool,
|
|
||||||
useDatabaseTables,
|
|
||||||
useDatabaseQueries,
|
|
||||||
useKillQuery,
|
|
||||||
} from '../../api/queries/admin/database';
|
|
||||||
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
|
|
||||||
import layout from '../../styles/AdminLayout.module.css';
|
|
||||||
import styles from './DatabaseAdminPage.module.css';
|
|
||||||
|
|
||||||
type Section = 'pool' | 'tables' | 'queries' | 'maintenance' | 'thresholds';
|
export default function DatabaseAdminPage() {
|
||||||
|
const { data: status } = useDatabaseStatus();
|
||||||
|
const { data: pool } = useConnectionPool();
|
||||||
|
const { data: tables } = useDatabaseTables();
|
||||||
|
const { data: queries } = useActiveQueries();
|
||||||
|
const killQuery = useKillQuery();
|
||||||
|
|
||||||
interface SectionDef {
|
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
|
||||||
id: Section;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTIONS: SectionDef[] = [
|
const tableColumns: Column<any>[] = [
|
||||||
{ id: 'pool', label: 'Connection Pool', icon: 'CP' },
|
{ key: 'tableName', header: 'Table' },
|
||||||
{ id: 'tables', label: 'Table Sizes', icon: 'TS' },
|
{ key: 'rowCount', header: 'Rows', sortable: true },
|
||||||
{ id: 'queries', label: 'Active Queries', icon: 'AQ' },
|
{ key: 'dataSize', header: 'Data Size' },
|
||||||
{ id: 'maintenance', label: 'Maintenance', icon: 'MN' },
|
{ key: 'indexSize', header: 'Index Size' },
|
||||||
{ id: 'thresholds', label: 'Thresholds', icon: 'TH' },
|
];
|
||||||
];
|
|
||||||
|
|
||||||
export function DatabaseAdminPage() {
|
const queryColumns: Column<any>[] = [
|
||||||
const roles = useAuthStore((s) => s.roles);
|
{ key: 'pid', header: 'PID' },
|
||||||
|
{ key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` },
|
||||||
if (!roles.includes('ADMIN')) {
|
{ key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> },
|
||||||
return (
|
{ key: 'query', header: 'Query', render: (v) => <span style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{String(v).slice(0, 80)}</span> },
|
||||||
<div className={layout.page}>
|
{
|
||||||
<div className={layout.accessDenied}>
|
key: 'pid', header: '', width: '80px',
|
||||||
Access Denied -- this page requires the ADMIN role.
|
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
|
||||||
</div>
|
},
|
||||||
</div>
|
];
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DatabaseAdminContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatabaseAdminContent() {
|
|
||||||
const [selectedSection, setSelectedSection] = useState<Section>('pool');
|
|
||||||
|
|
||||||
const status = useDatabaseStatus();
|
|
||||||
const pool = useDatabasePool();
|
|
||||||
const tables = useDatabaseTables();
|
|
||||||
const queries = useDatabaseQueries();
|
|
||||||
const thresholds = useThresholds();
|
|
||||||
|
|
||||||
if (status.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.loading}>Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = status.data;
|
|
||||||
|
|
||||||
function getMiniStatus(section: Section): string {
|
|
||||||
switch (section) {
|
|
||||||
case 'pool': {
|
|
||||||
const d = pool.data;
|
|
||||||
if (!d) return '--';
|
|
||||||
const pct = d.maxPoolSize > 0 ? Math.round((d.activeConnections / d.maxPoolSize) * 100) : 0;
|
|
||||||
return `${pct}%`;
|
|
||||||
}
|
|
||||||
case 'tables':
|
|
||||||
return tables.data ? `${tables.data.length}` : '--';
|
|
||||||
case 'queries':
|
|
||||||
return queries.data ? `${queries.data.length}` : '--';
|
|
||||||
case 'maintenance':
|
|
||||||
return 'Coming soon';
|
|
||||||
case 'thresholds':
|
|
||||||
return thresholds.data ? 'Configured' : '--';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.panelHeader}>
|
|
||||||
<div>
|
<div>
|
||||||
<div className={layout.panelTitle}>Database</div>
|
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
|
||||||
<div className={layout.panelSubtitle}>
|
|
||||||
<StatusBadge
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
status={db?.connected ? 'healthy' : 'critical'}
|
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
||||||
label={db?.connected ? 'Connected' : 'Disconnected'}
|
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||||
/>
|
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
|
||||||
{db?.version && <span className={styles.metaItem}>{db.version}</span>}
|
|
||||||
{db?.host && <span className={styles.metaItem}>{db.host}</span>}
|
|
||||||
{db?.schema && <span className={styles.metaItem}>Schema: {db.schema}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={layout.btnAction}
|
|
||||||
onClick={() => {
|
|
||||||
status.refetch();
|
|
||||||
pool.refetch();
|
|
||||||
tables.refetch();
|
|
||||||
queries.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh All
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={layout.split}>
|
{pool && (
|
||||||
<div className={layout.listPane}>
|
<Card>
|
||||||
<div className={layout.entityList}>
|
<div style={{ padding: '1rem' }}>
|
||||||
{SECTIONS.map((sec) => (
|
<h3 style={{ marginBottom: '0.5rem' }}>Connection Pool</h3>
|
||||||
<div
|
<ProgressBar value={poolPct} />
|
||||||
key={sec.id}
|
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
className={`${layout.entityItem} ${selectedSection === sec.id ? layout.entityItemSelected : ''}`}
|
<span>Active: {pool.activeConnections}</span>
|
||||||
onClick={() => setSelectedSection(sec.id)}
|
<span>Idle: {pool.idleConnections}</span>
|
||||||
>
|
<span>Max: {pool.maximumPoolSize}</span>
|
||||||
<div className={layout.sectionIcon}>{sec.icon}</div>
|
|
||||||
<div className={layout.entityInfo}>
|
|
||||||
<div className={layout.entityName}>{sec.label}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={layout.miniStatus}>{getMiniStatus(sec.id)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.75rem' }}>Tables</h3>
|
||||||
|
<DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={layout.detailPane}>
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
{selectedSection === 'pool' && (
|
<h3 style={{ marginBottom: '0.75rem' }}>Active Queries</h3>
|
||||||
<PoolSection
|
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
|
||||||
pool={pool}
|
|
||||||
warningPct={thresholds.data?.database?.connectionPoolWarning}
|
|
||||||
criticalPct={thresholds.data?.database?.connectionPoolCritical}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedSection === 'tables' && <TablesSection tables={tables} />}
|
|
||||||
{selectedSection === 'queries' && (
|
|
||||||
<QueriesSection
|
|
||||||
queries={queries}
|
|
||||||
warningSeconds={thresholds.data?.database?.queryDurationWarning}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedSection === 'maintenance' && <MaintenanceSection />}
|
|
||||||
{selectedSection === 'thresholds' && (
|
|
||||||
<ThresholdsSection thresholds={thresholds.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PoolSection({
|
|
||||||
pool,
|
|
||||||
warningPct,
|
|
||||||
criticalPct,
|
|
||||||
}: {
|
|
||||||
pool: ReturnType<typeof useDatabasePool>;
|
|
||||||
warningPct?: number;
|
|
||||||
criticalPct?: number;
|
|
||||||
}) {
|
|
||||||
const data = pool.data;
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const usagePct = data.maxPoolSize > 0
|
|
||||||
? Math.round((data.activeConnections / data.maxPoolSize) * 100)
|
|
||||||
: 0;
|
|
||||||
const barColor =
|
|
||||||
criticalPct && usagePct >= criticalPct ? '#ef4444'
|
|
||||||
: warningPct && usagePct >= warningPct ? '#eab308'
|
|
||||||
: '#22c55e';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Connection Pool</div>
|
|
||||||
<div className={styles.progressContainer}>
|
|
||||||
<div className={styles.progressLabel}>
|
|
||||||
{data.activeConnections} / {data.maxPoolSize} connections
|
|
||||||
<span className={styles.progressPct}>{usagePct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.progressBar}>
|
|
||||||
<div
|
|
||||||
className={styles.progressFill}
|
|
||||||
style={{ width: `${usagePct}%`, background: barColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.activeConnections}</span>
|
|
||||||
<span className={styles.metricLabel}>Active</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.idleConnections}</span>
|
|
||||||
<span className={styles.metricLabel}>Idle</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.pendingThreads}</span>
|
|
||||||
<span className={styles.metricLabel}>Pending</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.maxWaitMs}ms</span>
|
|
||||||
<span className={styles.metricLabel}>Max Wait</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
|
|
||||||
const data = tables.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Table Sizes</div>
|
|
||||||
{!data ? (
|
|
||||||
<div className={layout.loading}>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.tableWrapper}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Table</th>
|
|
||||||
<th>Rows</th>
|
|
||||||
<th>Data Size</th>
|
|
||||||
<th>Index Size</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((t) => (
|
|
||||||
<tr key={t.tableName}>
|
|
||||||
<td className={styles.mono}>{t.tableName}</td>
|
|
||||||
<td>{t.rowCount.toLocaleString()}</td>
|
|
||||||
<td>{t.dataSize}</td>
|
|
||||||
<td>{t.indexSize}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QueriesSection({
|
|
||||||
queries,
|
|
||||||
warningSeconds,
|
|
||||||
}: {
|
|
||||||
queries: ReturnType<typeof useDatabaseQueries>;
|
|
||||||
warningSeconds?: number;
|
|
||||||
}) {
|
|
||||||
const [killTarget, setKillTarget] = useState<number | null>(null);
|
|
||||||
const killMutation = useKillQuery();
|
|
||||||
const data = queries.data;
|
|
||||||
|
|
||||||
const warningSec = warningSeconds ?? 30;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Active Queries</div>
|
|
||||||
{!data || data.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>No active queries</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.tableWrapper}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>PID</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th>Query</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((q) => (
|
|
||||||
<tr
|
|
||||||
key={q.pid}
|
|
||||||
className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
|
|
||||||
>
|
|
||||||
<td className={styles.mono}>{q.pid}</td>
|
|
||||||
<td>{formatDuration(q.durationSeconds)}</td>
|
|
||||||
<td>{q.state}</td>
|
|
||||||
<td className={styles.queryCell} title={q.query}>
|
|
||||||
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.killBtn}
|
|
||||||
onClick={() => setKillTarget(q.pid)}
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
isOpen={killTarget !== null}
|
|
||||||
onClose={() => setKillTarget(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (killTarget !== null) {
|
|
||||||
killMutation.mutate(killTarget);
|
|
||||||
setKillTarget(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
resourceName={String(killTarget ?? '')}
|
|
||||||
resourceType="query (PID)"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MaintenanceSection() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Maintenance</div>
|
|
||||||
<div className={styles.maintenanceGrid}>
|
|
||||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
|
||||||
VACUUM ANALYZE
|
|
||||||
</button>
|
|
||||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
|
||||||
REINDEX
|
|
||||||
</button>
|
|
||||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
|
||||||
Refresh Aggregates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
|
||||||
const [form, setForm] = useState<ThresholdConfig | null>(null);
|
|
||||||
const saveMutation = useSaveThresholds();
|
|
||||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
|
||||||
|
|
||||||
const current = form ?? thresholds;
|
|
||||||
if (!current) return null;
|
|
||||||
|
|
||||||
function updateDb(key: keyof ThresholdConfig['database'], value: number) {
|
|
||||||
setForm((prev) => {
|
|
||||||
const base = prev ?? thresholds!;
|
|
||||||
return { ...base, database: { ...base.database, [key]: value } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!form && !thresholds) return;
|
|
||||||
const data = form ?? thresholds!;
|
|
||||||
try {
|
|
||||||
await saveMutation.mutateAsync(data);
|
|
||||||
setStatus({ type: 'success', msg: 'Thresholds saved.' });
|
|
||||||
setTimeout(() => setStatus(null), 3000);
|
|
||||||
} catch {
|
|
||||||
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Thresholds</div>
|
|
||||||
<div className={styles.thresholdGrid}>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Pool Warning %</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.database.connectionPoolWarning}
|
|
||||||
onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Pool Critical %</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.database.connectionPoolCritical}
|
|
||||||
onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Query Warning (s)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.database.queryDurationWarning}
|
|
||||||
onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Query Critical (s)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.database.queryDurationCritical}
|
|
||||||
onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.btnPrimary}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
|
|
||||||
</button>
|
|
||||||
{status && (
|
|
||||||
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
|
|
||||||
{status.msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
|
||||||
const s = Math.floor(seconds);
|
|
||||||
if (s < 60) return `${s}s`;
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
return `${m}m ${s % 60}s`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
/* ─── Toggle ─── */
|
|
||||||
.toggleRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 0;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleInfo {
|
|
||||||
flex: 1;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleDesc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: 24px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--text-muted);
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s, background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleOn {
|
|
||||||
background: var(--amber);
|
|
||||||
border-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleOn::after {
|
|
||||||
transform: translateX(20px);
|
|
||||||
background: #0a0e17;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Form Fields ─── */
|
|
||||||
.field {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tags ─── */
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 99px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagRemove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagRemove:hover {
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagInput {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagInput .input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagAddBtn {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagAddBtn:hover {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Header Action Button Variants ─── */
|
|
||||||
.btnPrimary {
|
|
||||||
border-color: var(--amber) !important;
|
|
||||||
background: var(--amber) !important;
|
|
||||||
color: #0a0e17 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:hover:not(:disabled) {
|
|
||||||
background: var(--amber-hover) !important;
|
|
||||||
border-color: var(--amber-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnOutline {
|
|
||||||
background: transparent;
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnOutline:hover:not(:disabled) {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnOutline:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDanger {
|
|
||||||
border-color: var(--rose-dim) !important;
|
|
||||||
color: var(--rose) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDanger:hover:not(:disabled) {
|
|
||||||
background: var(--rose-glow) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDanger:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Confirm Bar ─── */
|
|
||||||
.confirmBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmBar button {
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Status Messages ─── */
|
|
||||||
.successMsg {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--rose-glow);
|
|
||||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Skeleton Loading ─── */
|
|
||||||
.skeleton {
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
height: 20px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeletonWide {
|
|
||||||
composes: skeleton;
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeletonMedium {
|
|
||||||
composes: skeleton;
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.4; }
|
|
||||||
50% { opacity: 0.8; }
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
|
||||||
import {
|
|
||||||
useOidcConfig,
|
|
||||||
useSaveOidcConfig,
|
|
||||||
useTestOidcConnection,
|
|
||||||
useDeleteOidcConfig,
|
|
||||||
} from '../../api/queries/oidc-admin';
|
|
||||||
import type { OidcAdminConfigRequest } from '../../api/types';
|
|
||||||
import layout from '../../styles/AdminLayout.module.css';
|
|
||||||
import styles from './OidcAdminPage.module.css';
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
enabled: boolean;
|
|
||||||
autoSignup: boolean;
|
|
||||||
issuerUri: string;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
rolesClaim: string;
|
|
||||||
defaultRoles: string[];
|
|
||||||
displayNameClaim: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyForm: FormData = {
|
|
||||||
enabled: false,
|
|
||||||
autoSignup: true,
|
|
||||||
issuerUri: '',
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
rolesClaim: 'realm_access.roles',
|
|
||||||
defaultRoles: ['VIEWER'],
|
|
||||||
displayNameClaim: 'name',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OidcAdminPage() {
|
|
||||||
const roles = useAuthStore((s) => s.roles);
|
|
||||||
|
|
||||||
if (!roles.includes('ADMIN')) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.accessDenied}>
|
|
||||||
Access Denied -- this page requires the ADMIN role.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <OidcAdminForm />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OidcAdminForm() {
|
|
||||||
const { data, isLoading } = useOidcConfig();
|
|
||||||
const saveMutation = useSaveOidcConfig();
|
|
||||||
const testMutation = useTestOidcConnection();
|
|
||||||
const deleteMutation = useDeleteOidcConfig();
|
|
||||||
|
|
||||||
const [form, setForm] = useState<FormData>(emptyForm);
|
|
||||||
const [secretTouched, setSecretTouched] = useState(false);
|
|
||||||
const [newRole, setNewRole] = useState('');
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
||||||
const statusTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data) return;
|
|
||||||
if (data.configured) {
|
|
||||||
setForm({
|
|
||||||
enabled: data.enabled ?? false,
|
|
||||||
autoSignup: data.autoSignup ?? true,
|
|
||||||
issuerUri: data.issuerUri ?? '',
|
|
||||||
clientId: data.clientId ?? '',
|
|
||||||
clientSecret: '',
|
|
||||||
rolesClaim: data.rolesClaim ?? 'realm_access.roles',
|
|
||||||
defaultRoles: data.defaultRoles ?? ['VIEWER'],
|
|
||||||
displayNameClaim: data.displayNameClaim ?? 'name',
|
|
||||||
});
|
|
||||||
setSecretTouched(false);
|
|
||||||
} else {
|
|
||||||
setForm(emptyForm);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
function showStatus(type: 'success' | 'error', message: string) {
|
|
||||||
setStatus({ type, message });
|
|
||||||
clearTimeout(statusTimer.current);
|
|
||||||
statusTimer.current = setTimeout(() => setStatus(null), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateField<K extends keyof FormData>(key: K, value: FormData[K]) {
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const payload: OidcAdminConfigRequest = {
|
|
||||||
...form,
|
|
||||||
clientSecret: secretTouched ? form.clientSecret : '********',
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await saveMutation.mutateAsync(payload);
|
|
||||||
showStatus('success', 'Configuration saved.');
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('error', e instanceof Error ? e.message : 'Failed to save.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTest() {
|
|
||||||
try {
|
|
||||||
const result = await testMutation.mutateAsync();
|
|
||||||
showStatus('success', `Provider reachable. Authorization endpoint: ${result.authorizationEndpoint}`);
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('error', e instanceof Error ? e.message : 'Test failed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync();
|
|
||||||
setForm(emptyForm);
|
|
||||||
setSecretTouched(false);
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
showStatus('success', 'Configuration deleted.');
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('error', e instanceof Error ? e.message : 'Failed to delete.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRole() {
|
|
||||||
const role = newRole.trim().toUpperCase();
|
|
||||||
if (role && !form.defaultRoles.includes(role)) {
|
|
||||||
updateField('defaultRoles', [...form.defaultRoles, role]);
|
|
||||||
}
|
|
||||||
setNewRole('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRole(role: string) {
|
|
||||||
updateField('defaultRoles', form.defaultRoles.filter((r) => r !== role));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.panelHeader}>
|
|
||||||
<div>
|
|
||||||
<div className={layout.panelTitle}>OIDC Configuration</div>
|
|
||||||
<div className={layout.panelSubtitle}>Configure external identity provider</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={layout.detailOnly}>
|
|
||||||
<div className={styles.skeletonWide} />
|
|
||||||
<div className={styles.skeletonMedium} />
|
|
||||||
<div className={styles.skeletonWide} />
|
|
||||||
<div className={styles.skeletonWide} />
|
|
||||||
<div className={styles.skeletonMedium} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isConfigured = data?.configured ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.panelHeader}>
|
|
||||||
<div>
|
|
||||||
<div className={layout.panelTitle}>OIDC Configuration</div>
|
|
||||||
<div className={layout.panelSubtitle}>Configure external identity provider</div>
|
|
||||||
</div>
|
|
||||||
<div className={layout.headerActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${layout.btnAction} ${styles.btnPrimary}`}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${layout.btnAction} ${styles.btnOutline}`}
|
|
||||||
onClick={handleTest}
|
|
||||||
disabled={!isConfigured || testMutation.isPending}
|
|
||||||
>
|
|
||||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${layout.btnAction} ${styles.btnDanger}`}
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
disabled={!isConfigured || deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.detailOnly}>
|
|
||||||
<div className={layout.detailSection}>
|
|
||||||
<div className={layout.detailSectionTitle}>Behavior</div>
|
|
||||||
|
|
||||||
<div className={styles.toggleRow}>
|
|
||||||
<div className={styles.toggleInfo}>
|
|
||||||
<div className={styles.toggleLabel}>Enabled</div>
|
|
||||||
<div className={styles.toggleDesc}>
|
|
||||||
Allow users to sign in with the configured OIDC identity provider
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
|
|
||||||
onClick={() => updateField('enabled', !form.enabled)}
|
|
||||||
aria-label="Toggle OIDC enabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.toggleRow}>
|
|
||||||
<div className={styles.toggleInfo}>
|
|
||||||
<div className={styles.toggleLabel}>Auto Sign-Up</div>
|
|
||||||
<div className={styles.toggleDesc}>
|
|
||||||
Automatically create accounts for new OIDC users. When disabled, an admin must
|
|
||||||
pre-create the user before they can sign in.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
|
|
||||||
onClick={() => updateField('autoSignup', !form.autoSignup)}
|
|
||||||
aria-label="Toggle auto sign-up"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.detailSection}>
|
|
||||||
<div className={layout.detailSectionTitle}>Provider Settings</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Issuer URI</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="url"
|
|
||||||
value={form.issuerUri}
|
|
||||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
|
||||||
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Client ID</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={form.clientId}
|
|
||||||
onChange={(e) => updateField('clientId', e.target.value)}
|
|
||||||
placeholder="cameleer3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Client Secret</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="password"
|
|
||||||
value={form.clientSecret}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateField('clientSecret', e.target.value);
|
|
||||||
setSecretTouched(true);
|
|
||||||
}}
|
|
||||||
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.detailSection}>
|
|
||||||
<div className={layout.detailSectionTitle}>Claim Mapping</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Roles Claim</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={form.rolesClaim}
|
|
||||||
onChange={(e) => updateField('rolesClaim', e.target.value)}
|
|
||||||
placeholder="realm_access.roles"
|
|
||||||
/>
|
|
||||||
<div className={styles.hint}>
|
|
||||||
Dot-separated path to roles array in the ID token
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Display Name Claim</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={form.displayNameClaim}
|
|
||||||
onChange={(e) => updateField('displayNameClaim', e.target.value)}
|
|
||||||
placeholder="name"
|
|
||||||
/>
|
|
||||||
<div className={styles.hint}>
|
|
||||||
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.detailSection}>
|
|
||||||
<div className={layout.detailSectionTitle}>Default Roles</div>
|
|
||||||
|
|
||||||
<div className={styles.tags}>
|
|
||||||
{form.defaultRoles.map((role) => (
|
|
||||||
<span key={role} className={styles.tag}>
|
|
||||||
{role}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.tagRemove}
|
|
||||||
onClick={() => removeRole(role)}
|
|
||||||
aria-label={`Remove ${role}`}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.tagInput}>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
value={newRole}
|
|
||||||
onChange={(e) => setNewRole(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addRole();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add role..."
|
|
||||||
/>
|
|
||||||
<button type="button" className={styles.tagAddBtn} onClick={addRole}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDeleteConfirm && (
|
|
||||||
<div className={styles.confirmBar}>
|
|
||||||
<span>Delete OIDC configuration? This cannot be undone.</span>
|
|
||||||
<div className={styles.confirmActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.btnOutline}
|
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.btnDanger}
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? 'Deleting...' : 'Confirm'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
|
|
||||||
{status.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
78
ui/src/pages/admin/OidcConfigPage.tsx
Normal file
78
ui/src/pages/admin/OidcConfigPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system';
|
||||||
|
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
|
|
||||||
|
interface OidcConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
issuerUri: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
rolesClaim: string;
|
||||||
|
defaultRoles: string[];
|
||||||
|
autoSignup: boolean;
|
||||||
|
displayNameClaim: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OidcConfigPage() {
|
||||||
|
const [config, setConfig] = useState<OidcConfig | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch<OidcConfig>('/oidc')
|
||||||
|
.then(setConfig)
|
||||||
|
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!config) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await adminFetch('/oidc', { method: 'DELETE' });
|
||||||
|
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
|
||||||
|
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
|
||||||
|
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
||||||
|
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete}>Remove Config</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
|
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
/* ─── Progress Bar ─── */
|
|
||||||
.progressContainer {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressLabel {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressPct {
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressBar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressFill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Metrics Grid ─── */
|
|
||||||
.metricsGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricValue {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Filter Row ─── */
|
|
||||||
.filterRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInput::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterSelect {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tables ─── */
|
|
||||||
.tableWrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortableHeader {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortableHeader:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortArrow {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthBadge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 99px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthGreen {
|
|
||||||
background: rgba(34, 197, 94, 0.1);
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthYellow {
|
|
||||||
background: rgba(234, 179, 8, 0.1);
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthRed {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteBtn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--rose-dim);
|
|
||||||
color: var(--rose);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteBtn:hover {
|
|
||||||
background: var(--rose-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Pagination ─── */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:hover:not(:disabled) {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageInfo {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Heap Section ─── */
|
|
||||||
.heapSection {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Operations ─── */
|
|
||||||
.operationsGrid {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.operationBtn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Thresholds ─── */
|
|
||||||
.thresholdGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdField {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdInput {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdInput:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdActions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
background: var(--amber);
|
|
||||||
color: #0a0e17;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:hover {
|
|
||||||
background: var(--amber-hover);
|
|
||||||
border-color: var(--amber-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnPrimary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.successMsg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaItem {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.metricsGrid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterRow {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,488 +1,58 @@
|
|||||||
|
import { StatCard, Card, DataTable, Badge, ProgressBar, Spinner } from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
|
||||||
import { StatusBadge, type Status } from '../../components/admin/StatusBadge';
|
|
||||||
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
|
||||||
import {
|
|
||||||
useOpenSearchStatus,
|
|
||||||
usePipelineStats,
|
|
||||||
useIndices,
|
|
||||||
usePerformanceStats,
|
|
||||||
useDeleteIndex,
|
|
||||||
type IndicesParams,
|
|
||||||
} from '../../api/queries/admin/opensearch';
|
|
||||||
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
|
|
||||||
import layout from '../../styles/AdminLayout.module.css';
|
|
||||||
import styles from './OpenSearchAdminPage.module.css';
|
|
||||||
|
|
||||||
type Section = 'pipeline' | 'indices' | 'performance' | 'operations' | 'thresholds';
|
export default function OpenSearchAdminPage() {
|
||||||
|
const { data: status } = useOpenSearchStatus();
|
||||||
|
const { data: pipeline } = usePipelineStats();
|
||||||
|
const { data: perf } = useOpenSearchPerformance();
|
||||||
|
const { data: indicesData } = useOpenSearchIndices();
|
||||||
|
const deleteIndex = useDeleteIndex();
|
||||||
|
|
||||||
function clusterHealthToStatus(health: string | undefined): Status {
|
const indexColumns: Column<any>[] = [
|
||||||
switch (health?.toLowerCase()) {
|
{ key: 'name', header: 'Index' },
|
||||||
case 'green': return 'healthy';
|
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
||||||
case 'yellow': return 'warning';
|
{ key: 'docCount', header: 'Documents', sortable: true },
|
||||||
case 'red': return 'critical';
|
{ key: 'size', header: 'Size' },
|
||||||
default: return 'unknown';
|
{ key: 'primaryShards', header: 'Shards' },
|
||||||
}
|
];
|
||||||
}
|
|
||||||
|
|
||||||
const SECTIONS: { key: Section; label: string; icon: string }[] = [
|
|
||||||
{ key: 'pipeline', label: 'Indexing Pipeline', icon: '>' },
|
|
||||||
{ key: 'indices', label: 'Indices', icon: '#' },
|
|
||||||
{ key: 'performance', label: 'Performance', icon: '~' },
|
|
||||||
{ key: 'operations', label: 'Operations', icon: '*' },
|
|
||||||
{ key: 'thresholds', label: 'Thresholds', icon: '=' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OpenSearchAdminPage() {
|
|
||||||
const roles = useAuthStore((s) => s.roles);
|
|
||||||
|
|
||||||
if (!roles.includes('ADMIN')) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.accessDenied}>
|
|
||||||
Access Denied -- this page requires the ADMIN role.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <OpenSearchAdminContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpenSearchAdminContent() {
|
|
||||||
const [selectedSection, setSelectedSection] = useState<Section>('pipeline');
|
|
||||||
|
|
||||||
const status = useOpenSearchStatus();
|
|
||||||
const pipeline = usePipelineStats();
|
|
||||||
const performance = usePerformanceStats();
|
|
||||||
const thresholds = useThresholds();
|
|
||||||
|
|
||||||
if (status.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.loading}>Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const os = status.data;
|
|
||||||
|
|
||||||
function getMiniStatus(key: Section): string {
|
|
||||||
switch (key) {
|
|
||||||
case 'pipeline':
|
|
||||||
return pipeline.data ? `Queue: ${pipeline.data.queueDepth}` : '--';
|
|
||||||
case 'indices':
|
|
||||||
return '--';
|
|
||||||
case 'performance':
|
|
||||||
return performance.data
|
|
||||||
? `${(performance.data.queryCacheHitRate * 100).toFixed(0)}% hit`
|
|
||||||
: '--';
|
|
||||||
case 'operations':
|
|
||||||
return 'Coming soon';
|
|
||||||
case 'thresholds':
|
|
||||||
return 'Configured';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={layout.page}>
|
|
||||||
<div className={layout.panelHeader}>
|
|
||||||
<div>
|
<div>
|
||||||
<div className={layout.panelTitle}>OpenSearch</div>
|
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
|
||||||
<div className={layout.panelSubtitle}>
|
|
||||||
<StatusBadge
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
status={clusterHealthToStatus(os?.clusterHealth)}
|
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
||||||
label={os?.clusterHealth ?? 'Unknown'}
|
<StatCard label="Health" value={status?.clusterHealth ?? '—'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
||||||
|
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||||
|
<StatCard label="Nodes" value={status?.numberOfNodes ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pipeline && (
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.5rem' }}>Indexing Pipeline</h3>
|
||||||
|
<ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} />
|
||||||
|
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
|
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
|
||||||
|
<span>Indexed: {pipeline.indexedCount}</span>
|
||||||
|
<span>Failed: {pipeline.failedCount}</span>
|
||||||
|
<span>Rate: {pipeline.indexingRate}/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.75rem' }}>Indices</h3>
|
||||||
|
<DataTable
|
||||||
|
columns={indexColumns}
|
||||||
|
data={(indicesData?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
|
||||||
|
sortable
|
||||||
|
pageSize={20}
|
||||||
/>
|
/>
|
||||||
{os?.version && <span>v{os.version}</span>}
|
|
||||||
{os?.nodeCount !== undefined && <span>{os.nodeCount} node(s)</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={layout.btnAction}
|
|
||||||
onClick={() => {
|
|
||||||
status.refetch();
|
|
||||||
pipeline.refetch();
|
|
||||||
performance.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.split}>
|
|
||||||
<div className={layout.listPane}>
|
|
||||||
<div className={layout.entityList}>
|
|
||||||
{SECTIONS.map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.key}
|
|
||||||
className={`${layout.entityItem} ${selectedSection === s.key ? layout.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedSection(s.key)}
|
|
||||||
>
|
|
||||||
<div className={layout.sectionIcon}>{s.icon}</div>
|
|
||||||
<div className={layout.entityInfo}>
|
|
||||||
<div className={layout.entityName}>{s.label}</div>
|
|
||||||
</div>
|
|
||||||
<div className={layout.miniStatus}>{getMiniStatus(s.key)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={layout.detailPane}>
|
|
||||||
{selectedSection === 'pipeline' && (
|
|
||||||
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
|
|
||||||
)}
|
|
||||||
{selectedSection === 'indices' && <IndicesSection />}
|
|
||||||
{selectedSection === 'performance' && (
|
|
||||||
<PerformanceSection performance={performance} thresholds={thresholds.data} />
|
|
||||||
)}
|
|
||||||
{selectedSection === 'operations' && <OperationsSection />}
|
|
||||||
{selectedSection === 'thresholds' && (
|
|
||||||
<OsThresholdsSection thresholds={thresholds.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PipelineSection({
|
|
||||||
pipeline,
|
|
||||||
thresholds,
|
|
||||||
}: {
|
|
||||||
pipeline: ReturnType<typeof usePipelineStats>;
|
|
||||||
thresholds?: ThresholdConfig;
|
|
||||||
}) {
|
|
||||||
const data = pipeline.data;
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const queuePct = data.maxQueueSize > 0
|
|
||||||
? Math.round((data.queueDepth / data.maxQueueSize) * 100)
|
|
||||||
: 0;
|
|
||||||
const barColor =
|
|
||||||
thresholds?.opensearch?.queueDepthCritical && data.queueDepth >= thresholds.opensearch.queueDepthCritical ? '#ef4444'
|
|
||||||
: thresholds?.opensearch?.queueDepthWarning && data.queueDepth >= thresholds.opensearch.queueDepthWarning ? '#eab308'
|
|
||||||
: '#22c55e';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Indexing Pipeline</div>
|
|
||||||
<div className={styles.progressContainer}>
|
|
||||||
<div className={styles.progressLabel}>
|
|
||||||
Queue: {data.queueDepth} / {data.maxQueueSize}
|
|
||||||
<span className={styles.progressPct}>{queuePct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.progressBar}>
|
|
||||||
<div
|
|
||||||
className={styles.progressFill}
|
|
||||||
style={{ width: `${queuePct}%`, background: barColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.indexedCount.toLocaleString()}</span>
|
|
||||||
<span className={styles.metricLabel}>Total Indexed</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.failedCount.toLocaleString()}</span>
|
|
||||||
<span className={styles.metricLabel}>Total Failed</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.indexingRate.toFixed(1)}/s</span>
|
|
||||||
<span className={styles.metricLabel}>Indexing Rate</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IndicesSection() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const pageSize = 10;
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const params: IndicesParams = {
|
|
||||||
search: search || undefined,
|
|
||||||
page,
|
|
||||||
size: pageSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
const indices = useIndices(params);
|
|
||||||
const deleteMutation = useDeleteIndex();
|
|
||||||
|
|
||||||
const data = indices.data;
|
|
||||||
const totalPages = data?.totalPages ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Indices</div>
|
|
||||||
<div className={styles.filterRow}>
|
|
||||||
<input
|
|
||||||
className={styles.filterInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search indices..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!data ? (
|
|
||||||
<div className={layout.loading}>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.tableWrapper}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Health</th>
|
|
||||||
<th>Docs</th>
|
|
||||||
<th>Size</th>
|
|
||||||
<th>Shards</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.indices.map((idx) => (
|
|
||||||
<tr key={idx.name}>
|
|
||||||
<td className={styles.mono}>{idx.name}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`${styles.healthBadge} ${styles[`health${idx.health.charAt(0).toUpperCase()}${idx.health.slice(1)}`]}`}>
|
|
||||||
{idx.health}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{idx.docCount.toLocaleString()}</td>
|
|
||||||
<td>{idx.size}</td>
|
|
||||||
<td>{idx.primaryShards}p / {idx.replicaShards}r</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.deleteBtn}
|
|
||||||
onClick={() => setDeleteTarget(idx.name)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{data.indices.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className={styles.emptyState}>No indices found</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.pageBtn}
|
|
||||||
disabled={page === 0}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className={styles.pageInfo}>
|
|
||||||
Page {page + 1} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.pageBtn}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
isOpen={deleteTarget !== null}
|
|
||||||
onClose={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (deleteTarget) {
|
|
||||||
deleteMutation.mutate(deleteTarget);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
resourceName={deleteTarget ?? ''}
|
|
||||||
resourceType="index"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerformanceSection({
|
|
||||||
performance,
|
|
||||||
thresholds,
|
|
||||||
}: {
|
|
||||||
performance: ReturnType<typeof usePerformanceStats>;
|
|
||||||
thresholds?: ThresholdConfig;
|
|
||||||
}) {
|
|
||||||
const data = performance.data;
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const heapPct = data.jvmHeapMaxBytes > 0
|
|
||||||
? Math.round((data.jvmHeapUsedBytes / data.jvmHeapMaxBytes) * 100)
|
|
||||||
: 0;
|
|
||||||
const heapColor =
|
|
||||||
thresholds?.opensearch?.jvmHeapCritical && heapPct >= thresholds.opensearch.jvmHeapCritical ? '#ef4444'
|
|
||||||
: thresholds?.opensearch?.jvmHeapWarning && heapPct >= thresholds.opensearch.jvmHeapWarning ? '#eab308'
|
|
||||||
: '#22c55e';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Performance</div>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
|
|
||||||
<span className={styles.metricLabel}>Query Cache Hit</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{(data.requestCacheHitRate * 100).toFixed(1)}%</span>
|
|
||||||
<span className={styles.metricLabel}>Request Cache Hit</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.searchLatencyMs.toFixed(1)}ms</span>
|
|
||||||
<span className={styles.metricLabel}>Query Latency</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.metric}>
|
|
||||||
<span className={styles.metricValue}>{data.indexingLatencyMs.toFixed(1)}ms</span>
|
|
||||||
<span className={styles.metricLabel}>Index Latency</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.heapSection}>
|
|
||||||
<div className={styles.progressLabel}>
|
|
||||||
JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)}
|
|
||||||
<span className={styles.progressPct}>{heapPct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.progressBar}>
|
|
||||||
<div
|
|
||||||
className={styles.progressFill}
|
|
||||||
style={{ width: `${heapPct}%`, background: heapColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationsSection() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Operations</div>
|
|
||||||
<div className={styles.operationsGrid}>
|
|
||||||
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
|
|
||||||
Force Merge
|
|
||||||
</button>
|
|
||||||
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
|
|
||||||
Flush
|
|
||||||
</button>
|
|
||||||
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
|
|
||||||
Clear Cache
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
|
||||||
const [form, setForm] = useState<ThresholdConfig | null>(null);
|
|
||||||
const saveMutation = useSaveThresholds();
|
|
||||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
|
||||||
|
|
||||||
const current = form ?? thresholds;
|
|
||||||
if (!current) return null;
|
|
||||||
|
|
||||||
function updateOs(key: keyof ThresholdConfig['opensearch'], value: number | string) {
|
|
||||||
setForm((prev) => {
|
|
||||||
const base = prev ?? thresholds!;
|
|
||||||
return { ...base, opensearch: { ...base.opensearch, [key]: value } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const data = form ?? thresholds!;
|
|
||||||
try {
|
|
||||||
await saveMutation.mutateAsync(data);
|
|
||||||
setStatus({ type: 'success', msg: 'Thresholds saved.' });
|
|
||||||
setTimeout(() => setStatus(null), 3000);
|
|
||||||
} catch {
|
|
||||||
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={layout.detailSectionTitle}>Thresholds</div>
|
|
||||||
<div className={styles.thresholdGrid}>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Queue Warning</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.opensearch.queueDepthWarning}
|
|
||||||
onChange={(e) => updateOs('queueDepthWarning', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Queue Critical</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.opensearch.queueDepthCritical}
|
|
||||||
onChange={(e) => updateOs('queueDepthCritical', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Heap Warning %</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.opensearch.jvmHeapWarning}
|
|
||||||
onChange={(e) => updateOs('jvmHeapWarning', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdField}>
|
|
||||||
<label className={styles.thresholdLabel}>Heap Critical %</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.thresholdInput}
|
|
||||||
value={current.opensearch.jvmHeapCritical}
|
|
||||||
onChange={(e) => updateOs('jvmHeapCritical', Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.thresholdActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.btnPrimary}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
|
|
||||||
</button>
|
|
||||||
{status && (
|
|
||||||
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
|
|
||||||
{status.msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|||||||
178
ui/src/pages/admin/RbacPage.tsx
Normal file
178
ui/src/pages/admin/RbacPage.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
|
||||||
|
Select, AlertDialog, StatCard, Spinner,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import {
|
||||||
|
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
|
||||||
|
useCreateUser, useUpdateUser, useDeleteUser,
|
||||||
|
useAssignRoleToUser, useRemoveRoleFromUser,
|
||||||
|
useAddUserToGroup, useRemoveUserFromGroup,
|
||||||
|
useCreateGroup, useUpdateGroup, useDeleteGroup,
|
||||||
|
useCreateRole, useUpdateRole, useDeleteRole,
|
||||||
|
useAssignRoleToGroup, useRemoveRoleFromGroup,
|
||||||
|
} from '../../api/queries/admin/rbac';
|
||||||
|
|
||||||
|
export default function RbacPage() {
|
||||||
|
const [tab, setTab] = useState('users');
|
||||||
|
const { data: stats } = useRbacStats();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||||
|
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||||
|
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Users', value: 'users', count: stats?.userCount },
|
||||||
|
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
|
||||||
|
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
|
||||||
|
]}
|
||||||
|
active={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{tab === 'users' && <UsersTab />}
|
||||||
|
{tab === 'groups' && <GroupsTab />}
|
||||||
|
{tab === 'roles' && <RolesTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab() {
|
||||||
|
const { data: users, isLoading } = useUsers();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||||
|
{ key: 'displayName', header: 'Display Name' },
|
||||||
|
{ key: 'email', header: 'Email' },
|
||||||
|
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
|
||||||
|
{
|
||||||
|
key: 'effectiveRoles', header: 'Roles',
|
||||||
|
render: (v) => (
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
|
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||||
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} data={rows} pageSize={20} />
|
||||||
|
|
||||||
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||||
|
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
|
||||||
|
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
|
||||||
|
title="Delete User"
|
||||||
|
description={`Are you sure you want to delete user "${deleteId}"?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsTab() {
|
||||||
|
const { data: groups, isLoading } = useGroups();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '' });
|
||||||
|
const createGroup = useCreateGroup();
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||||
|
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
|
||||||
|
{
|
||||||
|
key: 'effectiveRoles', header: 'Roles',
|
||||||
|
render: (v) => (
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
|
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||||
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} data={groups || []} pageSize={20} />
|
||||||
|
|
||||||
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||||
|
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||||
|
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolesTab() {
|
||||||
|
const { data: roles, isLoading } = useRoles();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', description: '', scope: '' });
|
||||||
|
const createRole = useCreateRole();
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||||
|
{ key: 'description', header: 'Description' },
|
||||||
|
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
|
||||||
|
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
|
||||||
|
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||||
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} data={roles || []} pageSize={20} />
|
||||||
|
|
||||||
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||||
|
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
|
||||||
|
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
|
||||||
|
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useRbacStats, useGroups } from '../../../api/queries/admin/rbac';
|
|
||||||
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
|
||||||
import styles from './RbacPage.module.css';
|
|
||||||
|
|
||||||
export function DashboardTab() {
|
|
||||||
const stats = useRbacStats();
|
|
||||||
const groups = useGroups();
|
|
||||||
|
|
||||||
const groupList: GroupDetail[] = groups.data ?? [];
|
|
||||||
|
|
||||||
// Build inheritance diagram data: top-level groups sorted alphabetically,
|
|
||||||
// children sorted alphabetically and indented below their parent.
|
|
||||||
const { topLevelGroups, childMap } = useMemo(() => {
|
|
||||||
const sorted = [...groupList].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
const top = sorted.filter((g) => !g.parentGroupId);
|
|
||||||
const cMap = new Map<string, GroupDetail[]>();
|
|
||||||
for (const g of sorted) {
|
|
||||||
if (g.parentGroupId) {
|
|
||||||
const children = cMap.get(g.parentGroupId) ?? [];
|
|
||||||
children.push(g);
|
|
||||||
cMap.set(g.parentGroupId, children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { topLevelGroups: top, childMap: cMap };
|
|
||||||
}, [groupList]);
|
|
||||||
|
|
||||||
// Derive roles from groups in tree order (top-level then children), collecting
|
|
||||||
// each group's directRoles, deduplicating by id and preserving first-seen order.
|
|
||||||
const roleList = useMemo(() => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: { id: string; name: string }[] = [];
|
|
||||||
for (const g of topLevelGroups) {
|
|
||||||
for (const r of g.directRoles) {
|
|
||||||
if (!seen.has(r.id)) {
|
|
||||||
seen.add(r.id);
|
|
||||||
result.push(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const child of childMap.get(g.id) ?? []) {
|
|
||||||
for (const r of child.directRoles) {
|
|
||||||
if (!seen.has(r.id)) {
|
|
||||||
seen.add(r.id);
|
|
||||||
result.push(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [topLevelGroups, childMap]);
|
|
||||||
|
|
||||||
// Collect unique users from all groups, sorted alphabetically by displayName.
|
|
||||||
const allUsers = useMemo(() => {
|
|
||||||
const userMap = new Map<string, string>();
|
|
||||||
for (const g of groupList) {
|
|
||||||
for (const m of g.members) {
|
|
||||||
userMap.set(m.userId, m.displayName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Map(
|
|
||||||
[...userMap.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
|
||||||
);
|
|
||||||
}, [groupList]);
|
|
||||||
|
|
||||||
if (stats.isLoading) {
|
|
||||||
return <div className={styles.loading}>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = stats.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.panelHeader}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.panelTitle}>RBAC Overview</div>
|
|
||||||
<div className={styles.panelSubtitle}>Inheritance model and system summary</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.overviewGrid}>
|
|
||||||
<div className={styles.statCard}>
|
|
||||||
<div className={styles.statLabel}>Users</div>
|
|
||||||
<div className={styles.statValue}>{s?.userCount ?? 0}</div>
|
|
||||||
<div className={styles.statSub}>{s?.activeUserCount ?? 0} active</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statCard}>
|
|
||||||
<div className={styles.statLabel}>Groups</div>
|
|
||||||
<div className={styles.statValue}>{s?.groupCount ?? 0}</div>
|
|
||||||
<div className={styles.statSub}>Nested up to {s?.maxGroupDepth ?? 0} levels</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statCard}>
|
|
||||||
<div className={styles.statLabel}>Roles</div>
|
|
||||||
<div className={styles.statValue}>{s?.roleCount ?? 0}</div>
|
|
||||||
<div className={styles.statSub}>Direct + inherited</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inhDiagram}>
|
|
||||||
<div className={styles.inhTitle}>Inheritance model</div>
|
|
||||||
<div className={styles.inhRow}>
|
|
||||||
<div className={styles.inhCol}>
|
|
||||||
<div className={styles.inhColTitle}>Groups</div>
|
|
||||||
{topLevelGroups.map((g) => (
|
|
||||||
<div key={g.id}>
|
|
||||||
<div className={`${styles.inhItem} ${styles.inhItemGroup}`}>{g.name}</div>
|
|
||||||
{(childMap.get(g.id) ?? []).map((child) => (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
className={`${styles.inhItem} ${styles.inhItemGroup} ${styles.inhItemChild}`}
|
|
||||||
>
|
|
||||||
{child.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.inhArrow}>→</div>
|
|
||||||
<div className={styles.inhCol}>
|
|
||||||
<div className={styles.inhColTitle}>Roles on groups</div>
|
|
||||||
{roleList.map((r) => (
|
|
||||||
<div key={r.id} className={`${styles.inhItem} ${styles.inhItemRole}`}>
|
|
||||||
{r.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.inhArrow}>→</div>
|
|
||||||
<div className={styles.inhCol}>
|
|
||||||
<div className={styles.inhColTitle}>Users inherit</div>
|
|
||||||
{Array.from(allUsers.entries())
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(([id, name]) => (
|
|
||||||
<div key={id} className={`${styles.inhItem} ${styles.inhItemUser}`}>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{allUsers.size > 5 && (
|
|
||||||
<div className={styles.inhItem} style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
|
||||||
+ {allUsers.size - 5} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.inheritNote} style={{ marginTop: 12 }}>
|
|
||||||
Users inherit all roles from every group they belong to — and transitively from parent
|
|
||||||
groups. Roles can also be assigned directly to users, overriding or extending inherited
|
|
||||||
permissions.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
useGroups,
|
|
||||||
useGroup,
|
|
||||||
useCreateGroup,
|
|
||||||
useDeleteGroup,
|
|
||||||
useUpdateGroup,
|
|
||||||
useAssignRoleToGroup,
|
|
||||||
useRemoveRoleFromGroup,
|
|
||||||
useRoles,
|
|
||||||
} from '../../../api/queries/admin/rbac';
|
|
||||||
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
|
||||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
|
||||||
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
|
||||||
import { hashColor } from './avatar-colors';
|
|
||||||
import styles from './RbacPage.module.css';
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGroupMeta(group: GroupDetail, groupMap: Map<string, GroupDetail>): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (group.parentGroupId) {
|
|
||||||
const parent = groupMap.get(group.parentGroupId);
|
|
||||||
parts.push(`Child of ${parent?.name ?? 'unknown'}`);
|
|
||||||
} else {
|
|
||||||
parts.push('Top-level');
|
|
||||||
}
|
|
||||||
if (group.childGroups.length > 0) {
|
|
||||||
parts.push(`${group.childGroups.length} child group${group.childGroups.length !== 1 ? 's' : ''}`);
|
|
||||||
}
|
|
||||||
parts.push(`${group.members.length} member${group.members.length !== 1 ? 's' : ''}`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
function walk(id: string) {
|
|
||||||
const g = allGroups.find(x => x.id === id);
|
|
||||||
if (!g) return;
|
|
||||||
for (const child of g.childGroups) {
|
|
||||||
if (!ids.has(child.id)) {
|
|
||||||
ids.add(child.id);
|
|
||||||
walk(child.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(groupId);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupsTab() {
|
|
||||||
const groups = useGroups();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [newParentId, setNewParentId] = useState('');
|
|
||||||
const [createError, setCreateError] = useState('');
|
|
||||||
const createGroup = useCreateGroup();
|
|
||||||
const { data: allRoles } = useRoles();
|
|
||||||
|
|
||||||
const groupDetail = useGroup(selectedId);
|
|
||||||
|
|
||||||
const groupMap = useMemo(() => {
|
|
||||||
const map = new Map<string, GroupDetail>();
|
|
||||||
for (const g of groups.data ?? []) {
|
|
||||||
map.set(g.id, g);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [groups.data]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const list = groups.data ?? [];
|
|
||||||
if (!filter) return list;
|
|
||||||
const lower = filter.toLowerCase();
|
|
||||||
return list.filter((g) => g.name.toLowerCase().includes(lower));
|
|
||||||
}, [groups.data, filter]);
|
|
||||||
|
|
||||||
if (groups.isLoading) {
|
|
||||||
return <div className={styles.loading}>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = groupDetail.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.panelHeader}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.panelTitle}>Groups</div>
|
|
||||||
<div className={styles.panelSubtitle}>
|
|
||||||
Organise users in nested hierarchies; roles propagate to all members
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add group</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.split}>
|
|
||||||
<div className={styles.listPane}>
|
|
||||||
<div className={styles.searchBar}>
|
|
||||||
<input
|
|
||||||
className={styles.searchInput}
|
|
||||||
placeholder="Search groups..."
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showCreateForm && (
|
|
||||||
<div className={styles.createForm}>
|
|
||||||
<div className={styles.createFormRow}>
|
|
||||||
<label className={styles.createFormLabel}>Name</label>
|
|
||||||
<input className={styles.createFormInput} value={newName}
|
|
||||||
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
|
|
||||||
placeholder="Group name" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className={styles.createFormRow}>
|
|
||||||
<label className={styles.createFormLabel}>Parent</label>
|
|
||||||
<select className={styles.createFormSelect} value={newParentId}
|
|
||||||
onChange={e => setNewParentId(e.target.value)}>
|
|
||||||
<option value="">(Top-level)</option>
|
|
||||||
{(groups.data || []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{createError && <div className={styles.createFormError}>{createError}</div>}
|
|
||||||
<div className={styles.createFormActions}>
|
|
||||||
<button type="button" className={styles.createFormBtn}
|
|
||||||
onClick={() => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); }}>Cancel</button>
|
|
||||||
<button type="button" className={styles.createFormBtnPrimary}
|
|
||||||
disabled={!newName.trim() || createGroup.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
createGroup.mutate({ name: newName.trim(), parentGroupId: newParentId || undefined }, {
|
|
||||||
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); },
|
|
||||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create group'),
|
|
||||||
});
|
|
||||||
}}>Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.entityList}>
|
|
||||||
{filtered.map((group) => {
|
|
||||||
const isSelected = group.id === selectedId;
|
|
||||||
const color = hashColor(group.name);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={group.id}
|
|
||||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedId(group.id)}
|
|
||||||
>
|
|
||||||
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
|
|
||||||
{getInitials(group.name)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityInfo}>
|
|
||||||
<div className={styles.entityName}>{group.name}</div>
|
|
||||||
<div className={styles.entityMeta}>{getGroupMeta(group, groupMap)}</div>
|
|
||||||
<div className={styles.tagList}>
|
|
||||||
{group.directRoles.map((r) => (
|
|
||||||
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
|
|
||||||
{r.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{group.effectiveRoles
|
|
||||||
.filter((er) => !group.directRoles.some((dr) => dr.id === er.id))
|
|
||||||
.map((r) => (
|
|
||||||
<span
|
|
||||||
key={r.id}
|
|
||||||
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.detailPane}>
|
|
||||||
{!detail ? (
|
|
||||||
<div className={styles.detailEmpty}>
|
|
||||||
<span>Select a group to view details</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<GroupDetailView
|
|
||||||
group={detail}
|
|
||||||
groupMap={groupMap}
|
|
||||||
allGroups={groups.data || []}
|
|
||||||
allRoles={allRoles || []}
|
|
||||||
onDeselect={() => setSelectedId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADMINS_GROUP_ID = '00000000-0000-0000-0000-000000000010';
|
|
||||||
|
|
||||||
function GroupDetailView({
|
|
||||||
group,
|
|
||||||
groupMap,
|
|
||||||
allGroups,
|
|
||||||
allRoles,
|
|
||||||
onDeselect,
|
|
||||||
}: {
|
|
||||||
group: GroupDetail;
|
|
||||||
groupMap: Map<string, GroupDetail>;
|
|
||||||
allGroups: GroupDetail[];
|
|
||||||
allRoles: Array<{ id: string; name: string }>;
|
|
||||||
onDeselect: () => void;
|
|
||||||
}) {
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
const [nameValue, setNameValue] = useState(group.name);
|
|
||||||
const [editingParent, setEditingParent] = useState(false);
|
|
||||||
const [parentValue, setParentValue] = useState(group.parentGroupId || '');
|
|
||||||
const deleteGroup = useDeleteGroup();
|
|
||||||
const updateGroup = useUpdateGroup();
|
|
||||||
const assignRole = useAssignRoleToGroup();
|
|
||||||
const removeRole = useRemoveRoleFromGroup();
|
|
||||||
|
|
||||||
const isBuiltIn = group.id === ADMINS_GROUP_ID;
|
|
||||||
|
|
||||||
// Reset editing state when group changes
|
|
||||||
const [prevGroupId, setPrevGroupId] = useState(group.id);
|
|
||||||
if (prevGroupId !== group.id) {
|
|
||||||
setPrevGroupId(group.id);
|
|
||||||
setEditingName(false);
|
|
||||||
setNameValue(group.name);
|
|
||||||
setEditingParent(false);
|
|
||||||
setParentValue(group.parentGroupId || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hierarchyLabel = group.parentGroupId
|
|
||||||
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
|
|
||||||
: 'Top-level group';
|
|
||||||
|
|
||||||
const inheritedRoles = group.effectiveRoles.filter(
|
|
||||||
(er) => !group.directRoles.some((dr) => dr.id === er.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableRoles = (allRoles || [])
|
|
||||||
.filter(r => !group.directRoles.some(dr => dr.id === r.id))
|
|
||||||
.map(r => ({ id: r.id, label: r.name }));
|
|
||||||
|
|
||||||
const descendantIds = getDescendantIds(group.id, allGroups);
|
|
||||||
const parentOptions = allGroups.filter(g => g.id !== group.id && !descendantIds.has(g.id));
|
|
||||||
|
|
||||||
// Build hierarchy tree
|
|
||||||
const tree = useMemo(() => {
|
|
||||||
const rows: { name: string; depth: number }[] = [];
|
|
||||||
// Walk up to find root
|
|
||||||
const ancestors: GroupDetail[] = [];
|
|
||||||
let current: GroupDetail | undefined = group;
|
|
||||||
while (current?.parentGroupId) {
|
|
||||||
const parent = groupMap.get(current.parentGroupId);
|
|
||||||
if (parent) ancestors.unshift(parent);
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < ancestors.length; i++) {
|
|
||||||
rows.push({ name: ancestors[i].name, depth: i });
|
|
||||||
}
|
|
||||||
rows.push({ name: group.name, depth: ancestors.length });
|
|
||||||
for (const child of group.childGroups) {
|
|
||||||
rows.push({ name: child.name, depth: ancestors.length + 1 });
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}, [group, groupMap]);
|
|
||||||
|
|
||||||
const color = hashColor(group.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.detailHeader}>
|
|
||||||
<div className={styles.detailHeaderInfo}>
|
|
||||||
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 10 }}>
|
|
||||||
{getInitials(group.name)}
|
|
||||||
</div>
|
|
||||||
{editingName ? (
|
|
||||||
<input
|
|
||||||
className={styles.editNameInput}
|
|
||||||
value={nameValue}
|
|
||||||
onChange={e => setNameValue(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (nameValue.trim() && nameValue !== group.name) {
|
|
||||||
updateGroup.mutate({ id: group.id, name: nameValue.trim(), parentGroupId: group.parentGroupId });
|
|
||||||
}
|
|
||||||
setEditingName(false);
|
|
||||||
}}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(group.name); setEditingName(false); } }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.detailName}
|
|
||||||
onClick={() => !isBuiltIn && setEditingName(true)}
|
|
||||||
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
|
|
||||||
title={isBuiltIn ? undefined : 'Click to edit'}>
|
|
||||||
{group.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.detailEmail}>{hierarchyLabel}</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className={styles.btnDelete}
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
disabled={isBuiltIn || deleteGroup.isPending}
|
|
||||||
title={isBuiltIn ? 'Built-in group cannot be deleted' : 'Delete group'}>Delete</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<span className={styles.fieldLabel}>ID</span>
|
|
||||||
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{group.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<span className={styles.fieldLabel}>Parent</span>
|
|
||||||
{editingParent ? (
|
|
||||||
<div className={styles.parentEditRow}>
|
|
||||||
<select className={styles.parentSelect} value={parentValue}
|
|
||||||
onChange={e => setParentValue(e.target.value)}>
|
|
||||||
<option value="">(Top-level)</option>
|
|
||||||
{parentOptions.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<button type="button" className={styles.parentEditBtn}
|
|
||||||
onClick={() => {
|
|
||||||
updateGroup.mutate(
|
|
||||||
{ id: group.id, name: group.name, parentGroupId: parentValue || null },
|
|
||||||
{ onSuccess: () => setEditingParent(false) }
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={updateGroup.isPending}>Save</button>
|
|
||||||
<button type="button" className={styles.parentEditBtn}
|
|
||||||
onClick={() => { setParentValue(group.parentGroupId || ''); setEditingParent(false); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className={styles.fieldVal}>
|
|
||||||
{hierarchyLabel}
|
|
||||||
{!isBuiltIn && (
|
|
||||||
<button type="button" className={styles.fieldEditBtn}
|
|
||||||
onClick={() => setEditingParent(true)}>Edit</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className={styles.divider} />
|
|
||||||
|
|
||||||
<div className={styles.detailSection}>
|
|
||||||
<div className={styles.detailSectionTitle}>
|
|
||||||
Members <span>direct</span>
|
|
||||||
</div>
|
|
||||||
{group.members.length === 0 ? (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct members</span>
|
|
||||||
) : (
|
|
||||||
group.members.map((m) => (
|
|
||||||
<span key={m.userId} className={styles.chip}>
|
|
||||||
{m.displayName}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{group.childGroups.length > 0 && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
|
|
||||||
+ all members of {group.childGroups.map((c) => c.name).join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group.childGroups.length > 0 && (
|
|
||||||
<div className={styles.detailSection}>
|
|
||||||
<div className={styles.detailSectionTitle}>Child groups</div>
|
|
||||||
{group.childGroups.map((c) => (
|
|
||||||
<span key={c.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.detailSection}>
|
|
||||||
<div className={styles.detailSectionTitle}>
|
|
||||||
Assigned roles <span>on this group</span>
|
|
||||||
</div>
|
|
||||||
{group.directRoles.length === 0 ? (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No roles assigned</span>
|
|
||||||
) : (
|
|
||||||
group.directRoles.map((r) => (
|
|
||||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
|
|
||||||
{r.name}
|
|
||||||
<button type="button" className={styles.chipRemove}
|
|
||||||
onClick={() => removeRole.mutate({ groupId: group.id, roleId: r.id })}
|
|
||||||
disabled={removeRole.isPending} title="Remove role">x</button>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<MultiSelectDropdown items={availableRoles}
|
|
||||||
onApply={async (ids) => { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }}
|
|
||||||
placeholder="Search roles..." />
|
|
||||||
{inheritedRoles.length > 0 && (
|
|
||||||
<div className={styles.inheritNote}>
|
|
||||||
{group.childGroups.length > 0
|
|
||||||
? `Child groups ${group.childGroups.map((c) => c.name).join(' and ')} inherit these roles, and may additionally carry their own.`
|
|
||||||
: 'Roles are inherited from parent groups in the hierarchy.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailSection}>
|
|
||||||
<div className={styles.detailSectionTitle}>Group hierarchy</div>
|
|
||||||
{tree.map((node, i) => (
|
|
||||||
<div key={i} className={styles.treeRow}>
|
|
||||||
{node.depth > 0 && (
|
|
||||||
<div className={styles.treeIndent}>
|
|
||||||
<div className={styles.treeCorner} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{node.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
|
|
||||||
onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
|
|
||||||
resourceName={group.name} resourceType="group" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
/* ─── Page Layout ─── */
|
|
||||||
.page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessDenied {
|
|
||||||
text-align: center;
|
|
||||||
padding: 64px 16px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tabs ─── */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
background: none;
|
|
||||||
border-top: none;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-bottom-color: var(--green);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Split Layout ─── */
|
|
||||||
.split {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listPane {
|
|
||||||
width: 52%;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailPane {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Panel Header ─── */
|
|
||||||
.panelHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 20px 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelTitle {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelSubtitle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnAdd {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnAdd:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Search Bar ─── */
|
|
||||||
.searchBar {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-base);
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput:focus {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Entity List ─── */
|
|
||||||
.entityList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItemSelected {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Avatars ─── */
|
|
||||||
.avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarUser {
|
|
||||||
background: rgba(59, 130, 246, 0.15);
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarGroup {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: var(--green);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarRole {
|
|
||||||
background: rgba(240, 180, 41, 0.15);
|
|
||||||
color: var(--amber);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Entity Info ─── */
|
|
||||||
.entityInfo {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityMeta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tags ─── */
|
|
||||||
.tagList {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagRole {
|
|
||||||
background: var(--amber-glow);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagGroup {
|
|
||||||
background: var(--green-glow);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagInherited {
|
|
||||||
opacity: 0.65;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Status Dot ─── */
|
|
||||||
.statusDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusActive {
|
|
||||||
background: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusInactive {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── OIDC Badge ─── */
|
|
||||||
.oidcBadge {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--cyan-glow);
|
|
||||||
color: var(--cyan);
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Lock Icon (system role) ─── */
|
|
||||||
.lockIcon {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Detail Pane ─── */
|
|
||||||
.detailEmpty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailAvatar {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailName {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailEmail {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailSection {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailSectionTitle {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailSectionTitle span {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Chips ─── */
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipRole {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--amber);
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipGroup {
|
|
||||||
border-color: var(--green);
|
|
||||||
color: var(--green);
|
|
||||||
background: var(--green-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipUser {
|
|
||||||
border-color: var(--blue);
|
|
||||||
color: var(--blue);
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipInherited {
|
|
||||||
border-style: dashed;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipSource {
|
|
||||||
font-size: 9px;
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Inherit Note ─── */
|
|
||||||
.inheritNote {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border-left: 2px solid var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Field Rows ─── */
|
|
||||||
.fieldRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
width: 70px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldVal {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldMono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tree ─── */
|
|
||||||
.treeRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.treeIndent {
|
|
||||||
width: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.treeCorner {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
border-bottom-left-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Overview / Dashboard ─── */
|
|
||||||
.overviewGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statValue {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statSub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Inheritance Diagram ─── */
|
|
||||||
.inhDiagram {
|
|
||||||
margin: 16px 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhTitle {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhCol {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhColTitle {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhArrow {
|
|
||||||
width: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 22px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhItem {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhItemGroup {
|
|
||||||
border-color: var(--green);
|
|
||||||
color: var(--green);
|
|
||||||
background: var(--green-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhItemRole {
|
|
||||||
border-color: var(--amber-dim);
|
|
||||||
color: var(--amber);
|
|
||||||
background: var(--amber-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhItemUser {
|
|
||||||
border-color: var(--blue);
|
|
||||||
color: var(--blue);
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inhItemChild {
|
|
||||||
margin-left: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Loading / Error ─── */
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 32px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabContent {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Multi-Select Dropdown ─── */
|
|
||||||
.multiSelectWrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addChip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px dashed var(--amber);
|
|
||||||
color: var(--amber);
|
|
||||||
background: rgba(240, 180, 41, 0.08);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addChip:hover {
|
|
||||||
background: rgba(240, 180, 41, 0.18);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
min-width: 220px;
|
|
||||||
max-height: 300px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownSearch {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownSearchInput {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownSearchInput:focus {
|
|
||||||
border-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItemCheckbox {
|
|
||||||
accent-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownFooter {
|
|
||||||
padding: 8px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownApply {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--amber);
|
|
||||||
color: #000;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownApply:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownEmpty {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Remove button on chips ─── */
|
|
||||||
.chipRemove {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.4;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: 2px;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: opacity 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipRemove:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipRemove:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Delete button ─── */
|
|
||||||
.btnDelete {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border: 1px solid var(--rose);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--rose);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDelete:hover {
|
|
||||||
background: rgba(244, 63, 94, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnDelete:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Inline Create Form ─── */
|
|
||||||
.createForm {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
width: 60px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormInput {
|
|
||||||
flex: 1;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormInput:focus {
|
|
||||||
border-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormSelect {
|
|
||||||
flex: 1;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormBtn {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormBtnPrimary {
|
|
||||||
composes: createFormBtn;
|
|
||||||
background: var(--amber);
|
|
||||||
border-color: var(--amber);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormBtnPrimary:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.createFormError {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Detail header with actions ─── */
|
|
||||||
.detailHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailHeaderInfo {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Parent group dropdown ─── */
|
|
||||||
.parentSelect {
|
|
||||||
padding: 3px 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Parent Edit Mode ─── */
|
|
||||||
.parentEditRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parentEditBtn {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parentEditBtn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldEditBtn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--amber);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldEditBtn:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Editable Name Input ─── */
|
|
||||||
.editNameInput {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 2px 6px;
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useSearchParams } from 'react-router';
|
|
||||||
import { useAuthStore } from '../../../auth/auth-store';
|
|
||||||
import { DashboardTab } from './DashboardTab';
|
|
||||||
import { UsersTab } from './UsersTab';
|
|
||||||
import { GroupsTab } from './GroupsTab';
|
|
||||||
import { RolesTab } from './RolesTab';
|
|
||||||
import styles from './RbacPage.module.css';
|
|
||||||
|
|
||||||
const TABS = ['dashboard', 'users', 'groups', 'roles'] as const;
|
|
||||||
type TabKey = (typeof TABS)[number];
|
|
||||||
|
|
||||||
const TAB_LABELS: Record<TabKey, string> = {
|
|
||||||
dashboard: 'Dashboard',
|
|
||||||
users: 'Users',
|
|
||||||
groups: 'Groups',
|
|
||||||
roles: 'Roles',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RbacPage() {
|
|
||||||
const roles = useAuthStore((s) => s.roles);
|
|
||||||
|
|
||||||
if (!roles.includes('ADMIN')) {
|
|
||||||
return (
|
|
||||||
<div className={styles.page}>
|
|
||||||
<div className={styles.accessDenied}>
|
|
||||||
Access Denied — this page requires the ADMIN role.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <RbacContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RbacContent() {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const rawTab = searchParams.get('tab');
|
|
||||||
const activeTab: TabKey = TABS.includes(rawTab as TabKey) ? (rawTab as TabKey) : 'dashboard';
|
|
||||||
|
|
||||||
function setTab(tab: TabKey) {
|
|
||||||
setSearchParams({ tab }, { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.page}>
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
|
||||||
onClick={() => setTab(tab)}
|
|
||||||
>
|
|
||||||
{TAB_LABELS[tab]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
{activeTab === 'dashboard' && <DashboardTab />}
|
|
||||||
{activeTab === 'users' && <UsersTab />}
|
|
||||||
{activeTab === 'groups' && <GroupsTab />}
|
|
||||||
{activeTab === 'roles' && <RolesTab />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user