feat: migrate UI to @cameleer/design-system, add backend endpoints
Some checks failed
CI / build (push) Failing after 47s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

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:
hsiegeln
2026-03-19 17:38:39 +01:00
parent 82124c3145
commit 2b111c603c
150 changed files with 2750 additions and 21779 deletions

View File

@@ -120,6 +120,7 @@ jobs:
done
docker buildx build --platform linux/amd64 \
-f ui/Dockerfile \
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
$TAGS \
--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 \

View File

@@ -1,17 +1,23 @@
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.AgentState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Periodic task that checks agent lifecycle and expires old commands.
* <p>
* Runs on a configurable fixed delay (default 10 seconds). Transitions
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
* expired pending commands.
* expired pending commands. Records lifecycle events for state transitions.
*/
@Component
public class AgentLifecycleMonitor {
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
private final AgentRegistryService registryService;
private final AgentEventService agentEventService;
public AgentLifecycleMonitor(AgentRegistryService registryService) {
public AgentLifecycleMonitor(AgentRegistryService registryService,
AgentEventService agentEventService) {
this.registryService = registryService;
this.agentEventService = agentEventService;
}
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
public void checkLifecycle() {
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.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) {
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;
}
}

View File

@@ -1,11 +1,13 @@
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 org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates the {@link AgentRegistryService} bean.
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
* <p>
* Follows the established pattern: core module plain class, app module bean config.
*/
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
config.getCommandExpiryMs()
);
}
@Bean
public AgentEventService agentEventService(AgentEventRepository repository) {
return new AgentEventService(repository);
}
}

View File

@@ -31,7 +31,9 @@ public class OpenApiConfig {
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
"StatsTimeseries", "TimeseriesBucket",
"SearchResultExecutionSummary", "UserInfo",
"ProcessorNode"
"ProcessorNode",
"AppCatalogEntry", "RouteSummary", "AgentSummary",
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
);
@Bean

View File

@@ -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);
}
}

View File

@@ -8,6 +8,7 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
import com.cameleer3.server.app.dto.ErrorResponse;
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.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
@@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Agent registration, heartbeat, listing, and token refresh endpoints.
@@ -50,17 +57,23 @@ public class AgentRegistrationController {
private final BootstrapTokenValidator bootstrapTokenValidator;
private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService;
private final AgentEventService agentEventService;
private final JdbcTemplate jdbc;
public AgentRegistrationController(AgentRegistryService registryService,
AgentRegistryConfig config,
BootstrapTokenValidator bootstrapTokenValidator,
JwtService jwtService,
Ed25519SigningService ed25519SigningService) {
Ed25519SigningService ed25519SigningService,
AgentEventService agentEventService,
JdbcTemplate jdbc) {
this.registryService = registryService;
this.config = config;
this.bootstrapTokenValidator = bootstrapTokenValidator;
this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService;
this.agentEventService = agentEventService;
this.jdbc = jdbc;
}
@PostMapping("/register")
@@ -97,6 +110,9 @@ public class AgentRegistrationController {
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
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
List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
@@ -171,7 +187,7 @@ public class AgentRegistrationController {
@GetMapping
@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 = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@@ -198,9 +214,52 @@ public class AgentRegistrationController {
.toList();
}
List<AgentInstanceResponse> response = agents.stream()
.map(AgentInstanceResponse::from)
// Enrich with runtime metrics from continuous aggregates
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();
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;
}
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -4,10 +4,11 @@ import com.cameleer3.server.core.agent.AgentInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@Schema(description = "Agent instance summary")
@Schema(description = "Agent instance summary with runtime metrics")
public record AgentInstanceResponse(
@NotNull String id,
@NotNull String name,
@@ -15,13 +16,29 @@ public record AgentInstanceResponse(
@NotNull String status,
@NotNull List<String> routeIds,
@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) {
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
return new AgentInstanceResponse(
info.id(), info.name(), info.group(),
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
);
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -81,6 +81,8 @@ public class SecurityConfig {
.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/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")
// Admin endpoints

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
) {}

View File

@@ -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);
}

View File

@@ -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
View File

@@ -0,0 +1 @@
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/

View File

@@ -1,8 +1,11 @@
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
ARG REGISTRY_TOKEN
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 . .

View File

@@ -5,7 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cameleer3</title>
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>

451
ui/package-lock.json generated
View File

@@ -8,14 +8,13 @@
"name": "ui",
"version": "0.0.0",
"dependencies": {
"@cameleer/design-system": "^0.0.1",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.1",
"swagger-ui-dist": "^5.32.0",
"uplot": "^1.6.32",
"zustand": "^5.0.11"
},
"devDependencies": {
@@ -197,23 +196,23 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -274,10 +273,25 @@
"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": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -287,9 +301,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -584,20 +598,10 @@
"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": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"version": "0.120.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -636,9 +640,9 @@
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.10",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz",
"integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==",
"version": "1.34.11",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -681,9 +685,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
"cpu": [
"arm64"
],
@@ -698,9 +702,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
"cpu": [
"arm64"
],
@@ -715,9 +719,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
"cpu": [
"x64"
],
@@ -732,9 +736,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
"cpu": [
"x64"
],
@@ -749,9 +753,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
"cpu": [
"arm"
],
@@ -766,9 +770,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
"cpu": [
"arm64"
],
@@ -783,9 +787,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
"cpu": [
"arm64"
],
@@ -800,9 +804,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
"cpu": [
"ppc64"
],
@@ -817,9 +821,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
"cpu": [
"s390x"
],
@@ -834,9 +838,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
"cpu": [
"x64"
],
@@ -851,9 +855,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
"cpu": [
"x64"
],
@@ -868,9 +872,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
"cpu": [
"arm64"
],
@@ -885,9 +889,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
"cpu": [
"wasm32"
],
@@ -902,9 +906,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
"cpu": [
"arm64"
],
@@ -919,9 +923,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
"cpu": [
"x64"
],
@@ -950,9 +954,9 @@
"license": "Apache-2.0"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
"integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -960,12 +964,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz",
"integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
"@tanstack/query-core": "5.91.2"
},
"funding": {
"type": "github",
@@ -1031,17 +1035,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/type-utils": "8.57.0",
"@typescript-eslint/utils": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/type-utils": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
@@ -1054,7 +1058,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.57.0",
"@typescript-eslint/parser": "^8.57.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -1070,16 +1074,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3"
},
"engines": {
@@ -1095,14 +1099,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.0",
"@typescript-eslint/types": "^8.57.0",
"@typescript-eslint/tsconfig-utils": "^8.57.1",
"@typescript-eslint/types": "^8.57.1",
"debug": "^4.4.3"
},
"engines": {
@@ -1117,14 +1121,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0"
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1135,9 +1139,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1152,15 +1156,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz",
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/utils": "8.57.0",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
@@ -1177,9 +1181,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1191,16 +1195,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.0",
"@typescript-eslint/tsconfig-utils": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"@typescript-eslint/project-service": "8.57.1",
"@typescript-eslint/tsconfig-utils": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1271,16 +1275,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz",
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0"
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1295,13 +1299,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/types": "8.57.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1401,15 +1405,6 @@
"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": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -1451,9 +1446,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
"integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
"integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1463,12 +1458,6 @@
"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": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -1525,9 +1514,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001778",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
"integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
"version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
"dev": true,
"funding": [
{
@@ -1681,9 +1670,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
"dev": true,
"license": "ISC"
},
@@ -1978,9 +1967,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -2597,12 +2586,6 @@
"dev": true,
"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": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -2709,17 +2692,6 @@
"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": {
"version": "1.0.1",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2914,14 +2902,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
"@oxc-project/types": "=0.120.0",
"@rolldown/pluginutils": "1.0.0-rc.10"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2930,27 +2918,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
"dev": true,
"license": "MIT"
},
@@ -3036,9 +3024,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz",
"integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==",
"version": "5.32.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz",
"integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -3062,9 +3050,9 @@
}
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3123,16 +3111,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz",
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/utils": "8.57.0"
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3184,12 +3172,6 @@
"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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3208,17 +3190,16 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"rolldown": "1.0.0-rc.10",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -3235,7 +3216,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"jiti": ">=1.21.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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3379,9 +3354,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"

View File

@@ -5,21 +5,20 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
"lint": "eslint .",
"preview": "vite preview",
"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"
},
"dependencies": {
"@cameleer/design-system": "^0.0.1",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.1",
"swagger-ui-dist": "^5.32.0",
"uplot": "^1.6.32",
"zustand": "^5.0.11"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface AuditEvent {
id: number;
timestamp: string;
@@ -8,18 +10,18 @@ export interface AuditEvent {
action: string;
category: string;
target: string;
detail: Record<string, unknown>;
detail: Record<string, unknown> | null;
result: string;
ipAddress: string;
userAgent: string;
}
export interface AuditLogParams {
from?: string;
to?: string;
username?: string;
category?: string;
search?: string;
from?: string;
to?: string;
sort?: string;
order?: string;
page?: number;
@@ -34,21 +36,25 @@ export interface AuditLogResponse {
totalPages: number;
}
export function useAuditLog(params: AuditLogParams) {
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();
// ── Query Hooks ────────────────────────────────────────────────────────
export function useAuditLog(params: AuditLogParams = {}) {
return useQuery({
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,
});
}

View File

@@ -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';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseStatus {
connected: boolean;
version: string;
host: string;
schema: string;
version: string | null;
host: string | null;
schema: string | null;
timescaleDb: boolean;
}
export interface PoolStats {
activeConnections: number;
idleConnections: number;
pendingThreads: number;
maxPoolSize: number;
maxWaitMs: number;
threadsAwaitingConnection: number;
connectionTimeout: number;
maximumPoolSize: number;
}
export interface TableInfo {
@@ -33,18 +35,21 @@ export interface ActiveQuery {
query: string;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useDatabaseStatus() {
return useQuery({
queryKey: ['admin', 'database', 'status'],
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
refetchInterval: 30_000,
});
}
export function useDatabasePool() {
export function useConnectionPool() {
return useQuery({
queryKey: ['admin', 'database', 'pool'],
queryFn: () => adminFetch<PoolStats>('/database/pool'),
refetchInterval: 15000,
refetchInterval: 10_000,
});
}
@@ -52,23 +57,27 @@ export function useDatabaseTables() {
return useQuery({
queryKey: ['admin', 'database', 'tables'],
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
refetchInterval: 60_000,
});
}
export function useDatabaseQueries() {
export function useActiveQueries() {
return useQuery({
queryKey: ['admin', 'database', 'queries'],
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
refetchInterval: 15000,
refetchInterval: 5_000,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useKillQuery() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (pid: number) => {
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
mutationFn: (pid: number) =>
adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
});
}

View File

@@ -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';
// ── Types ──────────────────────────────────────────────────────────────
export interface OpenSearchStatus {
reachable: boolean;
connected: boolean;
clusterHealth: string;
version: string;
nodeCount: number;
host: string;
version: string | null;
numberOfNodes: number;
url: string;
}
export interface PipelineStats {
queueDepth: number;
maxQueueSize: number;
indexedCount: number;
failedCount: number;
indexedCount: number;
debounceMs: number;
indexingRate: number;
lastIndexedAt: string | null;
@@ -21,15 +23,15 @@ export interface PipelineStats {
export interface IndexInfo {
name: string;
health: string;
docCount: number;
size: string;
sizeBytes: number;
health: string;
primaryShards: number;
replicaShards: number;
replicas: number;
}
export interface IndicesPageResponse {
export interface IndicesPage {
indices: IndexInfo[];
totalIndices: number;
totalDocs: number;
@@ -44,20 +46,17 @@ export interface PerformanceStats {
requestCacheHitRate: number;
searchLatencyMs: number;
indexingLatencyMs: number;
jvmHeapUsedBytes: number;
jvmHeapMaxBytes: number;
heapUsedBytes: number;
heapMaxBytes: number;
}
export interface IndicesParams {
search?: string;
page?: number;
size?: number;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useOpenSearchStatus() {
return useQuery({
queryKey: ['admin', 'opensearch', 'status'],
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
refetchInterval: 30_000,
});
}
@@ -65,42 +64,41 @@ export function usePipelineStats() {
return useQuery({
queryKey: ['admin', 'opensearch', 'pipeline'],
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
refetchInterval: 15000,
refetchInterval: 10_000,
});
}
export function useIndices(params: IndicesParams) {
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();
export function useOpenSearchIndices(page = 0, size = 20, search = '') {
return useQuery({
queryKey: ['admin', 'opensearch', 'indices', params],
queryFn: () =>
adminFetch<IndicesPageResponse>(
`/opensearch/indices${qs ? `?${qs}` : ''}`,
),
queryKey: ['admin', 'opensearch', 'indices', page, size, search],
queryFn: () => {
const params = new URLSearchParams();
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({
queryKey: ['admin', 'opensearch', 'performance'],
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
refetchInterval: 15000,
refetchInterval: 30_000,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useDeleteIndex() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (indexName: string) => {
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, {
method: 'DELETE',
});
mutationFn: (indexName: string) =>
adminFetch<void>(`/opensearch/indices/${indexName}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }),
});
}

View File

@@ -1,24 +1,23 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ── Types ───
// ── Types ──────────────────────────────────────────────────────────────
export interface RoleSummary {
id: string;
name: string;
system: boolean;
source: string;
scope: string;
}
export interface GroupSummary {
id: string;
name: string;
parentGroupId: string | null;
}
export interface UserSummary {
userId: string;
displayName: string;
provider: string;
}
export interface UserDetail {
@@ -33,17 +32,6 @@ export interface UserDetail {
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 {
id: string;
name: string;
@@ -56,6 +44,53 @@ export interface RoleDetail {
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 {
userCount: number;
activeUserCount: number;
@@ -64,53 +99,6 @@ export interface RbacStats {
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() {
return useQuery({
queryKey: ['admin', 'rbac', 'stats'],
@@ -118,162 +106,69 @@ export function useRbacStats() {
});
}
// ─── Mutation hooks ───
// ── User Query Hooks ───────────────────────────────────────────────────
export function useAssignRoleToUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => adminFetch<UserDetail[]>('/users'),
});
}
export function useRemoveRoleFromUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useUser(userId: string | null) {
return useQuery({
queryKey: ['admin', 'users', userId],
queryFn: () => adminFetch<UserDetail>(`/users/${userId}`),
enabled: !!userId,
});
}
export function useAddUserToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
// ── Role Query Hooks ───────────────────────────────────────────────────
export function useRoles() {
return useQuery({
queryKey: ['admin', 'roles'],
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
});
}
export function useRemoveUserFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useRole(roleId: string | null) {
return useQuery({
queryKey: ['admin', 'roles', roleId],
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
enabled: !!roleId,
});
}
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parentGroupId?: string }) =>
adminFetch<{ id: string }>('/groups', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
// ── Group Query Hooks ──────────────────────────────────────────────────
export function useGroups() {
return useQuery({
queryKey: ['admin', 'groups'],
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
});
}
export function useUpdateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
adminFetch(`/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useGroup(groupId: string | null) {
return useQuery({
queryKey: ['admin', 'groups', groupId],
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
enabled: !!groupId,
});
}
export function useDeleteGroup() {
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'] });
},
});
}
// ── User Mutation Hooks ────────────────────────────────────────────────
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
mutationFn: (req: CreateUserRequest) =>
adminFetch<UserDetail>('/users', {
method: 'POST',
body: JSON.stringify(data),
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
@@ -281,13 +176,13 @@ export function useCreateUser() {
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, {
mutationFn: ({ userId, ...req }: UpdateUserRequest & { userId: string }) =>
adminFetch<void>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
@@ -296,9 +191,163 @@ export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
adminFetch<void>(`/users/${userId}`, { method: 'DELETE' }),
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'] });
},
});
}

View File

@@ -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';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseThresholds {
connectionPoolWarning: number;
connectionPoolCritical: number;
@@ -24,6 +26,8 @@ export interface ThresholdConfig {
opensearch: OpenSearchThresholds;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useThresholds() {
return useQuery({
queryKey: ['admin', 'thresholds'],
@@ -31,15 +35,18 @@ export function useThresholds() {
});
}
export function useSaveThresholds() {
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useUpdateThresholds() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ThresholdConfig) => {
await adminFetch<ThresholdConfig>('/thresholds', {
mutationFn: (config: ThresholdConfig) =>
adminFetch<ThresholdConfig>('/thresholds', {
method: 'PUT',
body: JSON.stringify(body),
});
body: JSON.stringify(config),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
});
}

View File

@@ -1,15 +1,40 @@
import { useQuery } from '@tanstack/react-query';
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({
queryKey: ['agents', status],
queryKey: ['agents', status, group],
queryFn: async () => {
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');
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,
});
}

View 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,
});
}

View File

@@ -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'] }),
});
}

3503
ui/src/api/schema.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -17,3 +17,8 @@ export type ErrorResponse = components['schemas']['ErrorResponse'];
export type DiagramLayout = components['schemas']['DiagramLayout'];
export type PositionedNode = components['schemas']['PositionedNode'];
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'];

View File

@@ -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);
}

View File

@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useState } from 'react';
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { api } from '../api/client';
import styles from './LoginPage.module.css';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
interface OidcInfo {
clientId: string;
@@ -50,62 +50,54 @@ export function LoginPage() {
};
return (
<div className={styles.page}>
<form className={styles.card} onSubmit={handleSubmit}>
<div 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
</div>
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Sign in to access the observability dashboard
</p>
</div>
{oidc && (
<>
<button
className={styles.ssoButton}
type="button"
onClick={handleOidcLogin}
disabled={oidcLoading}
>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</button>
<div className={styles.divider}>
<span className={styles.dividerText}>or</span>
</div>
</>
)}
{oidc && (
<>
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
<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 className={styles.field}>
<label className={styles.label}>Username</label>
<input
className={styles.input}
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</div>
<FormField label="Username">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</FormField>
<div className={styles.field}>
<label className={styles.label}>Password</label>
<input
className={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<FormField label="Password">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormField>
<button className={styles.submit} type="submit" disabled={loading || !username || !password}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
{error && <div className={styles.error}>{error}</div>}
</form>
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
</form>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { Navigate, useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import styles from './LoginPage.module.css';
import { Card, Spinner, Alert, Button } from '@cameleer/design-system';
export function OidcCallback() {
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
@@ -36,29 +36,21 @@ export function OidcCallback() {
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className={styles.page}>
<div className={styles.card}>
<div 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
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<div style={{ padding: '2rem', textAlign: 'center', minWidth: 320 }}>
<h2 style={{ marginBottom: '1rem' }}>cameleer3</h2>
{loading && <Spinner />}
{error && (
<>
<Alert variant="error">{error}</Alert>
<Button variant="secondary" onClick={() => navigate('/login')} style={{ marginTop: 16 }}>
Back to Login
</Button>
</>
)}
</div>
{loading && <div className={styles.subtitle}>Completing sign-in...</div>}
{error && (
<>
<div className={styles.error}>{error}</div>
<button
className={styles.submit}
style={{ marginTop: 16 }}
onClick={() => navigate('/login')}
>
Back to Login
</button>
</>
)}
</div>
</Card>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { useAuth } from './use-auth';
export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Initialize auth hooks (auto-refresh, API client wiring)
useAuth();
if (!isAuthenticated) return <Navigate to="/login" replace />;

View File

@@ -7,7 +7,6 @@ export function useAuth() {
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
const navigate = useNavigate();
// Wire onUnauthorized handler (needs navigate from router context)
useEffect(() => {
configureAuth({
onUnauthorized: async () => {
@@ -20,7 +19,6 @@ export function useAuth() {
});
}, [navigate]);
// Auto-refresh: check token expiry every 30s
useEffect(() => {
if (!isAuthenticated) return;
const interval = setInterval(async () => {
@@ -29,12 +27,11 @@ export function useAuth() {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
// Refresh when less than 5 minutes remaining
if (expiresIn < 5 * 60 * 1000) {
await refresh();
}
} catch {
// Token parse failure — ignore, will fail on next API call
// Token parse failure
}
}, 30_000);
return () => clearInterval(interval);

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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} &lsquo;{resourceName}&rsquo;? 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>
);
}

View File

@@ -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); }
}

View File

@@ -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}`}>
&#9654;
</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"
>
&#8635;
</button>
)}
</div>
{!collapsed && <div className={styles.body}>{children}</div>}
</div>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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%' }} />;
}

View File

@@ -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} />;
}

View File

@@ -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 },
},
};
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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}>&uarr;</kbd>
<kbd className={styles.kbd}>&darr;</kbd> navigate
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>&crarr;</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>
);
}

View File

@@ -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)}>
&times;
</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }),
}));

View File

@@ -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,
};
}

View File

@@ -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`;
}

View File

@@ -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);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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}>&#9881;</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>
)}
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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}>&#8984;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">
&#x2715;
</button>
</span>
)}
</div>
</nav>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
>
&#8249;
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`e${i}`} className={styles.pageEllipsis}>&hellip;</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}
>
&#8250;
</button>
</div>
);
}

View File

@@ -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)'; }}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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
View 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%;
}

View File

@@ -1,11 +1,11 @@
import '@cameleer/design-system/style.css';
import './index.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './theme/ThemeProvider';
import { ThemeProvider } from '@cameleer/design-system';
import { router } from './router';
import './theme/fonts.css';
import './theme/tokens.css';
const queryClient = new QueryClient({
defaultOptions: {

View 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>
);
}

View 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`;
}

View 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;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -1,277 +1,59 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css';
import { useState, useMemo } from 'react';
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuditLog } from '../../api/queries/admin/audit';
function defaultFrom(): string {
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('');
export default function AuditLogPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const pageSize = 25;
const params: AuditLogParams = {
from: from || undefined,
to: to || undefined,
username: username || undefined,
category: category || undefined,
search: search || undefined,
page,
size: pageSize,
};
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
const audit = useAuditLog(params);
const data = audit.data;
const totalPages = data?.totalPages ?? 0;
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const columns: Column<any>[] = [
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'action', header: 'Action' },
{ 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 (
<div className={layout.page}>
{/* Header */}
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Audit Log</div>
<div className={layout.panelSubtitle}>
{data
? `${data.totalCount.toLocaleString()} events`
: 'Loading...'}
</div>
</div>
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
<Select
options={[
{ value: '', label: 'All Categories' },
{ value: 'AUTH', label: 'Auth' },
{ value: 'CONFIG', label: 'Config' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'INFRA', label: 'Infra' },
]}
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
{/* Filter bar */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</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}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<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>
{/* Table area */}
<div className={styles.tableArea}>
{audit.isLoading ? (
<div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>
No audit events found for the selected filters.
<DataTable
columns={columns}
data={rows}
sortable
pageSize={25}
expandedContent={(row) => (
<div style={{ padding: '0.75rem' }}>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
</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>
);
}
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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,437 +1,67 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { StatusBadge } from '../../components/admin/StatusBadge';
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';
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
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 {
id: Section;
label: string;
icon: string;
}
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
const SECTIONS: SectionDef[] = [
{ id: 'pool', label: 'Connection Pool', icon: 'CP' },
{ id: 'tables', label: 'Table Sizes', icon: 'TS' },
{ id: 'queries', label: 'Active Queries', icon: 'AQ' },
{ id: 'maintenance', label: 'Maintenance', icon: 'MN' },
{ id: 'thresholds', label: 'Thresholds', icon: 'TH' },
];
const tableColumns: Column<any>[] = [
{ key: 'tableName', header: 'Table' },
{ key: 'rowCount', header: 'Rows', sortable: true },
{ key: 'dataSize', header: 'Data Size' },
{ key: 'indexSize', header: 'Index Size' },
];
export function DatabaseAdminPage() {
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 <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' : '--';
}
}
const queryColumns: Column<any>[] = [
{ key: 'pid', header: 'PID' },
{ key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` },
{ key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> },
{ key: 'query', header: 'Query', render: (v) => <span style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{String(v).slice(0, 80)}</span> },
{
key: 'pid', header: '', width: '80px',
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
},
];
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Database</div>
<div className={layout.panelSubtitle}>
<StatusBadge
status={db?.connected ? 'healthy' : 'critical'}
label={db?.connected ? 'Connected' : 'Disconnected'}
/>
{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>
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
<StatCard label="Version" value={status?.version ?? '—'} />
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
</div>
<div className={layout.split}>
<div className={layout.listPane}>
<div className={layout.entityList}>
{SECTIONS.map((sec) => (
<div
key={sec.id}
className={`${layout.entityItem} ${selectedSection === sec.id ? layout.entityItemSelected : ''}`}
onClick={() => setSelectedSection(sec.id)}
>
<div className={layout.sectionIcon}>{sec.icon}</div>
<div className={layout.entityInfo}>
<div className={layout.entityName}>{sec.label}</div>
</div>
<div className={layout.miniStatus}>{getMiniStatus(sec.id)}</div>
</div>
))}
{pool && (
<Card>
<div style={{ padding: '1rem' }}>
<h3 style={{ marginBottom: '0.5rem' }}>Connection Pool</h3>
<ProgressBar value={poolPct} />
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
<span>Active: {pool.activeConnections}</span>
<span>Idle: {pool.idleConnections}</span>
<span>Max: {pool.maximumPoolSize}</span>
</div>
</div>
</div>
</Card>
)}
<div className={layout.detailPane}>
{selectedSection === 'pool' && (
<PoolSection
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 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 style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginBottom: '0.75rem' }}>Active Queries</h3>
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
</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`;
}

View File

@@ -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; }
}

View File

@@ -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}`}
>
&#x2715;
</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>
);
}

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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 { 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 {
switch (health?.toLowerCase()) {
case 'green': return 'healthy';
case 'yellow': return 'warning';
case 'red': return 'critical';
default: return 'unknown';
}
}
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';
}
}
const indexColumns: Column<any>[] = [
{ key: 'name', header: 'Index' },
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
{ key: 'docCount', header: 'Documents', sortable: true },
{ key: 'size', header: 'Size' },
{ key: 'primaryShards', header: 'Shards' },
];
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>OpenSearch</div>
<div className={layout.panelSubtitle}>
<StatusBadge
status={clusterHealthToStatus(os?.clusterHealth)}
label={os?.clusterHealth ?? 'Unknown'}
/>
{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>
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
<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>
<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>
))}
{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>
</div>
</Card>
)}
<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 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}
/>
</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]}`;
}

View 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>
);
}

View File

@@ -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}>&rarr;</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}>&rarr;</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>
);
}

View File

@@ -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" />
</>
);
}

View File

@@ -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;
}

View File

@@ -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