feat: implement Phase 2 (Go Agent) and Phase 3 (React Frontend MVP)

Phase 2 - Go Agent Core:
- gRPC client with exponential backoff reconnect logic
- Command executor (PowerShell/sh cross-platform)
- Proto stubs regenerated with module= option (correct output path)
- gRPC upgraded to v1.79.3 (BidiStreamingClient support)

Phase 3 - React Frontend MVP:
- Vite + React 18 + TypeScript setup with Tailwind CSS v4
- TanStack Query for data fetching, API client + TypeScript types
- Dashboard page: stats cards (agents/status/tickets) + sortable agents table
- Agent detail page: CPU/RAM charts (Recharts), disk usage, shell command executor
- Tickets page: CRUD with modals, filters, sortable table
- Dark mode with CSS custom properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 12:42:52 +01:00
parent 51052261f5
commit 418fc5b6d5
30 changed files with 7670 additions and 24 deletions

View File

@@ -0,0 +1,83 @@
package connection
import (
"context"
"fmt"
"log"
"time"
pb "nexusrmm.local/agent/pkg/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
)
type GrpcClient struct {
conn *grpc.ClientConn
Client pb.AgentServiceClient
address string
}
func NewGrpcClient(address string) (*GrpcClient, error) {
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}),
}
conn, err := grpc.NewClient(address, opts...)
if err != nil {
return nil, err
}
return &GrpcClient{
conn: conn,
Client: pb.NewAgentServiceClient(conn),
address: address,
}, nil
}
func (g *GrpcClient) Close() {
if g.conn != nil {
g.conn.Close()
}
}
func ConnectWithRetry(address string, maxRetries int) (*GrpcClient, error) {
for i := 0; i < maxRetries; i++ {
client, err := NewGrpcClient(address)
if err != nil {
log.Printf("Client creation %d/%d failed: %v", i+1, maxRetries, err)
time.Sleep(backoffDuration(i))
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
client.conn.Connect()
state := client.conn.GetState()
client.conn.WaitForStateChange(ctx, state)
cancel()
newState := client.conn.GetState()
if newState == connectivity.Ready || newState == connectivity.Idle {
return client, nil
}
log.Printf("Connection attempt %d/%d: state=%v", i+1, maxRetries, newState)
client.Close()
time.Sleep(backoffDuration(i))
}
return nil, fmt.Errorf("failed to connect after %d attempts", maxRetries)
}
func backoffDuration(attempt int) time.Duration {
secs := 2 << attempt
if secs > 30 {
secs = 30
}
return time.Duration(secs) * time.Second
}

View File

@@ -0,0 +1,49 @@
package executor
import (
"bytes"
"context"
"os/exec"
"runtime"
"time"
)
type Result struct {
ExitCode int
Stdout string
Stderr string
Success bool
}
func Execute(ctx context.Context, command string, timeoutSec int) *Result {
if timeoutSec <= 0 {
timeoutSec = 300
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command)
} else {
cmd = exec.CommandContext(ctx, "/bin/sh", "-c", command)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result := &Result{
Stdout: stdout.String(),
Stderr: stderr.String(),
Success: err == nil,
}
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
}
if err != nil && result.ExitCode == 0 {
result.ExitCode = -1
}
return result
}