Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Server Manager Utilities #90

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
13 changes: 2 additions & 11 deletions backend/internal/compilerServer/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,7 @@ func (app *CompiledApp) IsAlive() bool {
return false
}

if !process.IsAlive() {
return false
}

// Send a ping to the process
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/health", process.Port))
if resp != nil {
resp.Body.Close()
}
return err == nil && resp.StatusCode == http.StatusOK
return process.CheckHealth()
}

func (app *CompiledApp) keepAlive() {
Expand Down Expand Up @@ -264,7 +255,7 @@ func (app *CompiledApp) StartServer() error {
}

// Send a ping to the process
if serverProcess.IsHealthy() {
if serverProcess.CheckHealth() {
if atomic.CompareAndSwapInt64(app.keepAliveRunning, 0, 1) {
go app.keepAlive()
}
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/identity/id.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func (id Id) String() string {

// Cleans inputs and then creates a category from them. If you have a valid category already,
// ust path.Join to combine it with another category.
//
// Example:
// identity.Category("app", "appId") -> "/app/appId"
// identity.Category("my", "app", "badString/../") -> "/my/app/badString%2F%2E%2E%2F
func Category(ids ...string) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/process/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ func (m *ProcessManager) IsAlive(id ProcessId) bool {
return r.IsAlive(id)
}

func (m *ProcessManager) CheckHealth(id ProcessId) bool {
r := m.ReadHandle()
defer r.Close()

return r.CheckHealth(id)
}

func (m *ProcessManager) CopyOutData() []Process {
r := m.ReadHandle()
defer r.Close()
Expand Down
12 changes: 10 additions & 2 deletions backend/internal/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,16 @@ func (r *RHandle) IsAlive(id ProcessId) bool {
return process.IsAlive()
}

func (proc *Process) IsHealthy() bool {
return proc.IsAlive() && proc.HealthCheck.Check(health.RunningProcessInfo{Pid: proc.Pid, Port: proc.Port})
func (r *RHandle) CheckHealth(id ProcessId) bool {
process, found := r.FindById(id)
if !found {
return false
}
return process.CheckHealth()
}

func (proc *Process) CheckHealth() bool {
return proc.HealthCheck.Check(health.RunningProcessInfo{Pid: proc.Pid, Port: proc.Port})
}

// This reads the path variable to find the right executable.
Expand Down
79 changes: 79 additions & 0 deletions backend/internal/server/process_rpc.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,89 @@
package server

import (
"net/http"

"robinplatform.dev/internal/identity"
"robinplatform.dev/internal/process"
"robinplatform.dev/internal/pubsub"
)

type StartProcessForAppInput struct {
AppId string `json:"appId"`
ProcessKey string `json:"processKey"`
Command string `json:"command"`
Args []string `json:"args"`
}

var StartProcessForApp = AppsRpcMethod[StartProcessForAppInput, map[string]any]{
Name: "StartProcess",
Run: func(req RpcRequest[StartProcessForAppInput]) (map[string]any, *HttpError) {
id := process.ProcessId{
Category: identity.Category("app", req.Data.AppId),
Key: req.Data.ProcessKey,
}

processConfig := process.ProcessConfig{
Command: req.Data.Command,
Args: req.Data.Args,
Id: id,
}

proc, err := process.Manager.Spawn(processConfig)
if err != nil {
return nil, Errorf(http.StatusInternalServerError, "Failed to spawn new process %s: %s", req.Data.AppId, err)
}

return map[string]any{
"processKey": proc.Id,
"pid": proc.Pid,
}, nil
},
}

type StopProcessForAppInput struct {
AppId string `json:"appId"`
ProcessKey string `json:"processKey"`
}

var StopProcessForApp = AppsRpcMethod[StartProcessForAppInput, map[string]any]{
Name: "StopProcess",
Run: func(req RpcRequest[StartProcessForAppInput]) (map[string]any, *HttpError) {
id := process.ProcessId{
Category: identity.Category("app", req.Data.AppId),
Key: req.Data.ProcessKey,
}

if err := process.Manager.Remove(id); err != nil {
return nil, Errorf(http.StatusInternalServerError, "Failed to kill process %s: %s", req.Data.AppId, err)
}

return map[string]any{}, nil
},
}

type CheckProcessHealthInput struct {
AppId string `json:"appId"`
ProcessKey string `json:"processKey"`
}

var CheckProcessHealth = AppsRpcMethod[CheckProcessHealthInput, map[string]any]{
Name: "CheckProcessHealth",
Run: func(req RpcRequest[CheckProcessHealthInput]) (map[string]any, *HttpError) {
id := process.ProcessId{
Category: identity.Category("app", req.Data.AppId),
Key: req.Data.ProcessKey,
}

isHealthy := process.Manager.CheckHealth(id)

return map[string]any{
"processKey": id,
"isHealthy": isHealthy,
}, nil
},
}

func PipeTopic[T any](topicId pubsub.TopicId, req *StreamRequest[T, any]) error {
sub, err := pubsub.SubscribeAny(&pubsub.Topics, topicId)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ func (server *Server) loadRpcMethods() {
CreateTopic.Register(server)
PublishTopic.Register(server)

StartProcessForApp.Register(server)
StopProcessForApp.Register(server)
CheckProcessHealth.Register(server)

// Streaming methods

wsHandler := &RpcWebsocket{}
Expand Down
104 changes: 104 additions & 0 deletions frontend/components/ProcessDebugger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { z } from 'zod';
import React from 'react';
import { runRpcQuery, useRpcQuery } from '../hooks/useRpcQuery';
import { useTopic } from '../../toolkit/react/stream';
import { ScrollWindow } from './ScrollWindow';
import toast from 'react-hot-toast';

type ProcessInfo = z.infer<typeof ProcessInfo>;
const ProcessInfo = z.object({});

type Process = z.infer<typeof Process>;
const Process = z.object({
id: z.object({
category: z.string(),
key: z.string(),
}),
command: z.string(),
args: z.array(z.string()),
});

// This is a temporary bit of code to just display what's in the processes DB
// to make writing other features easier
export function ProcessDebugger() {
const { data: processes = [], error } = useRpcQuery({
method: 'ListProcesses',
data: {},
result: z.array(Process),
});

const [currentProcess, setCurrentProcess] = React.useState<Process>();
const { state } = useTopic({
topicId: currentProcess && {
category: `/logs${currentProcess.id.category}`,
key: currentProcess.id.key,
},
resultType: z.string(),
fetchState: () =>
runRpcQuery({
method: 'GetProcessLogs',
data: { processId: currentProcess?.id },
result: z.object({
counter: z.number(),
text: z.string(),
}),
}).then(({ counter, text }) => ({ counter, state: text })),
reducer: (prev, message) => {
return prev + '\n' + message;
},
});

React.useEffect(() => {
if (error) {
toast.error(`${String(error)}`);
}
}, [error]);

return (
<div
className={'full col robin-rounded robin-gap robin-pad'}
style={{ backgroundColor: 'DarkSlateGray', maxHeight: '100%' }}
>
<div>Processes</div>

<ScrollWindow className={'full'} innerClassName={'col robin-gap'}>
{processes?.map((value) => {
const key = `${value.id.category} ${value.id.key}`;
return (
<div
key={key}
className={'robin-rounded robin-pad'}
style={{ backgroundColor: 'Coral', width: '100%' }}
>
{key}

<button onClick={() => setCurrentProcess(value)}>Select</button>

<pre
style={{
width: '100%',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
})}
</ScrollWindow>

<ScrollWindow className={'full'} innerClassName={'col robin-gap'}>
<pre
style={{
width: '100%',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
{state}
</pre>
</ScrollWindow>
</div>
);
}
Loading