feat: update agent button on server page
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
||||
if err := agentsync.Run(ctx, cfg); err != nil {
|
||||
if err := agentsync.Run(ctx, cfg, Version); err != nil {
|
||||
log.Fatalf("agent error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +73,14 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
AgentVersion: version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -23,8 +23,9 @@ type RegisterResponse struct {
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
@@ -49,12 +50,18 @@ type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
|
||||
+108
-5
@@ -2,10 +2,15 @@ package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,7 +21,7 @@ import (
|
||||
"github.com/mrhid6/keymanager/agent/internal/keys"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config) error {
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial grpc: %w", err)
|
||||
@@ -60,7 +65,7 @@ func Run(ctx context.Context, cfg *config.Config) error {
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
if err := poll(client, cfg); err != nil {
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
|
||||
@@ -69,15 +74,15 @@ func Run(ctx context.Context, cfg *config.Config) error {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := poll(client, cfg); err != nil {
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken)
|
||||
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
@@ -165,6 +170,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.DeleteKey != nil {
|
||||
go handleDeleteKey(cmd)
|
||||
}
|
||||
if cmd.UpdateAgent != nil {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +192,101 @@ func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH // "amd64" or "arm64"
|
||||
tag := "agent%2Fv" + u.Version
|
||||
binaryURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/keymanager-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
// Download binary
|
||||
tmpBin := "/tmp/keymanager-agent-update"
|
||||
if err := downloadFile(binaryURL, tmpBin); err != nil {
|
||||
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Download and verify checksum
|
||||
checksumData, err := httpGetBytes(checksumURL)
|
||||
if err != nil {
|
||||
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := verifyChecksum(tmpBin, fmt.Sprintf("keymanager-agent-linux-%s", arch), checksumData); err != nil {
|
||||
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
|
||||
os.Remove(tmpBin)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpBin, 0755); err != nil {
|
||||
log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpBin, "/usr/local/bin/keymanager-agent"); err != nil {
|
||||
log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId)
|
||||
exec.Command("systemctl", "restart", "keymanager-agent").Run()
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func httpGetBytes(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func verifyChecksum(filePath, filename string, checksumData []byte) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
for _, line := range strings.Split(string(checksumData), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[1] == filename {
|
||||
if fields[0] != actual {
|
||||
return fmt.Errorf("expected %s got %s", fields[0], actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no checksum entry found for %s", filename)
|
||||
}
|
||||
|
||||
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
g := cmd.GenerateKey
|
||||
label := g.Label
|
||||
|
||||
@@ -25,8 +25,9 @@ message RegisterResponse {
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string agent_version = 3;
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
@@ -69,6 +70,7 @@ message ServerCommand {
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,11 @@ message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
|
||||
message UpdateAgentCmd {
|
||||
string version = 1; // e.g. "1.2.3"
|
||||
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
|
||||
}
|
||||
|
||||
message GenerateKeyCmd {
|
||||
string label = 1;
|
||||
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
|
||||
|
||||
@@ -32,6 +32,9 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.GET("/servers/:id", getServer)
|
||||
apiGroup.DELETE("/servers/:id", deleteServer)
|
||||
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.POST("/servers/:id/update-agent", updateAgent)
|
||||
|
||||
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
|
||||
|
||||
apiGroup.GET("/keys", listKeys)
|
||||
apiGroup.POST("/keys", createKey)
|
||||
@@ -259,6 +262,34 @@ func revokeAssignment(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
|
||||
func getLatestAgentVersion(c *gin.Context) {
|
||||
version, err := services.GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"version": version})
|
||||
}
|
||||
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
version, err := services.DispatchUpdateAgent(s.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "update command sent to agent",
|
||||
"version": version,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
|
||||
@@ -26,8 +26,9 @@ type RegisterResponse struct {
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
@@ -52,12 +53,18 @@ type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID); err != nil {
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID, req.AgentVersion); err != nil {
|
||||
log.Printf("failed to update last seen for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServe
|
||||
return status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID); err != nil {
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID, ""); err != nil {
|
||||
log.Printf("update last seen %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -67,6 +71,69 @@ type KeyGenParams struct {
|
||||
Comment string
|
||||
}
|
||||
|
||||
// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag
|
||||
// and returns just the version number (e.g. "1.2.3").
|
||||
func GetLatestAgentVersion() (string, error) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/keymanager/releases?limit=20", giteaHost)
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch releases: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Gitea API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return "", fmt.Errorf("decode releases: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if strings.HasPrefix(r.TagName, "agent/v") {
|
||||
return strings.TrimPrefix(r.TagName, "agent/v"), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no agent release found")
|
||||
}
|
||||
|
||||
// DispatchUpdateAgent sends an update command to the named server's agent.
|
||||
// It fetches the latest version from Gitea and includes the download base URL.
|
||||
func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||
}
|
||||
|
||||
version, err := GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get latest version: %w", err)
|
||||
}
|
||||
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
cmdID := uuid.New().String()
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: cmdID,
|
||||
UpdateAgent: &pb.UpdateAgentCmd{
|
||||
Version: version,
|
||||
GiteaBaseURL: "https://" + giteaHost,
|
||||
},
|
||||
}
|
||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// DispatchDeleteKey sends a delete-key command to the named server's agent.
|
||||
// It is best-effort: if the agent is offline the local files will remain until next connection.
|
||||
func DispatchDeleteKey(serverID, label string) {
|
||||
|
||||
@@ -134,14 +134,18 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateServerLastSeen(serverID string) error {
|
||||
func UpdateServerLastSeen(serverID, agentVersion string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
fields := bson.M{"last_seen": now, "status": "active"}
|
||||
if agentVersion != "" {
|
||||
fields["agent_version"] = agentVersion
|
||||
}
|
||||
_, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": serverID},
|
||||
bson.M{"$set": bson.M{"last_seen": now, "status": "active"}},
|
||||
bson.M{"$set": fields},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export default function ServerDetailPage() {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
|
||||
const { data: server, isLoading, error } = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
@@ -204,6 +205,20 @@ export default function ServerDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
@@ -290,24 +305,50 @@ export default function ServerDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
Run this command on the server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code> to update the agent to the latest version:
|
||||
</p>
|
||||
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{latestVersion ? `v${latestVersion.version}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && (
|
||||
<Badge variant="warning">update available</Badge>
|
||||
)}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && (
|
||||
<Badge variant="success">up to date</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">$</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||
</pre>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -326,6 +367,12 @@ export default function ServerDetailPage() {
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Server {
|
||||
ip_address: string;
|
||||
os_info: string;
|
||||
status: ServerStatus;
|
||||
agent_version?: string;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -116,6 +117,16 @@ export const api = {
|
||||
return `curl -fsSL "${window.location.origin}/update" | bash`;
|
||||
},
|
||||
|
||||
getLatestAgentVersion(): Promise<{ version: string }> {
|
||||
return request<{ version: string }>("/agent/latest-version");
|
||||
},
|
||||
|
||||
updateAgent(serverId: string): Promise<{ message: string; version: string }> {
|
||||
return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
// Keys
|
||||
listKeys(): Promise<Key[]> {
|
||||
return request<Key[]>("/keys");
|
||||
|
||||
Reference in New Issue
Block a user