saving
This commit is contained in:
commit
2e80560ea1
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
zig-out
|
||||
.zig-cache
|
||||
webtest/webtest.exe
|
||||
webtest/webtest
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const zargs_dep = b.dependency("zargs", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
const zargs_mod = zargs_dep.module("zargs");
|
||||
|
||||
const mod = b.addModule("crasa-transport", .{
|
||||
.root_source_file = b.path("src/root.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
});
|
||||
|
||||
const tests = b.addTest(.{ .root_module = mod });
|
||||
const testStep = b.step("test", "runs tests associated with the crasa-transportprotocol");
|
||||
testStep.dependOn(&tests.step);
|
||||
|
||||
const server_mod = b.createModule(.{
|
||||
.root_source_file = b.path("test/server.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
server_mod.addImport("crasa-transport", mod);
|
||||
server_mod.addImport("zargs", zargs_mod);
|
||||
const server_exe = b.addExecutable(.{
|
||||
.name = "test-server",
|
||||
.root_module = server_mod,
|
||||
});
|
||||
b.installArtifact(server_exe);
|
||||
|
||||
const client_mod = b.createModule(.{
|
||||
.root_source_file = b.path("test/client.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
client_mod.addImport("crasa-transport", mod);
|
||||
client_mod.addImport("zargs", zargs_mod);
|
||||
const client_exe = b.addExecutable(.{
|
||||
.name = "test-client",
|
||||
.root_module = client_mod,
|
||||
});
|
||||
b.installArtifact(client_exe);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.{
|
||||
.name = .transport,
|
||||
.version = "0.15.2",
|
||||
.dependencies = .{
|
||||
.zargs = .{
|
||||
.url = "git+https://git.peterino.com/peterino/zargs.git#d980c5bfe9a2328e7e335f72fccc7ef96dd3d27d",
|
||||
.hash = "zargs-0.0.0-CRr7fB6LAQAb5pI7v2FlN9z0cNDDRAT3GkIJ80GBYGxD",
|
||||
},
|
||||
},
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x66ab212ecbb3a7e9,
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub const log = std.log.scoped(.transport);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
test "hello world" {}
|
||||
|
||||
pub const udp = @import("udp.zig");
|
||||
pub const logging = @import("logging.zig");
|
||||
|
||||
pub const UdpEngine = udp.UdpEngine;
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// a bidirectional reliable udp transport
|
||||
//
|
||||
// I'm going to call this the CRU protocol
|
||||
//
|
||||
// stands for crasa-reliable-udp
|
||||
//
|
||||
// packet layout of the payload
|
||||
|
||||
// this results in a non blocking transport engine where you can poll for when new messages are
|
||||
// available. And the messages are sent through as fast as they can be, mangled/unacked messages
|
||||
// are replayed by the server over time.
|
||||
//
|
||||
// messages will arrive from the server to the client /eventually/
|
||||
//
|
||||
// they will arrive out of order as well.
|
||||
pub const UdpEngine = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
port: u16 = 6700,
|
||||
signalExit: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
||||
address: std.net.Address,
|
||||
|
||||
pub fn create(allocator: std.mem.Allocator) !*@This() {
|
||||
const self = try allocator.create(@This());
|
||||
|
||||
self.* = .{
|
||||
.allocator = allocator,
|
||||
.address = try std.net.Address.parseIp4("0.0.0.0", 5000),
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// requires a memory safe allocator, as this is the Allocator
|
||||
// that will be used to create the backing allocator on the working thread.
|
||||
pub fn serve(self: *@This(), port: u16) !void {
|
||||
self.port = port;
|
||||
self.address = try std.net.Address.parseIp4("0.0.0.0", port);
|
||||
const thread = try std.Thread.spawn(.{}, startServer, .{self});
|
||||
thread.detach();
|
||||
}
|
||||
|
||||
pub fn connect(self: *@This(), host: []const u8, port: u16) !void {
|
||||
log.info("parsing host '{s}'", .{host});
|
||||
const resolved_host = if (std.mem.eql(u8, host, "localhost"))
|
||||
"127.0.0.1"
|
||||
else
|
||||
host;
|
||||
self.address = try std.net.Address.resolveIp(resolved_host, port);
|
||||
self.port = port;
|
||||
const thread = try std.Thread.spawn(.{}, startClient, .{self});
|
||||
thread.detach();
|
||||
}
|
||||
|
||||
pub fn startServer(self: *@This()) void {
|
||||
const sockfd = posix.socket(
|
||||
self.address.any.family,
|
||||
posix.SOCK.DGRAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK,
|
||||
posix.IPPROTO.UDP,
|
||||
) catch |err| {
|
||||
log.err("server socket error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
defer posix.close(sockfd);
|
||||
|
||||
posix.bind(sockfd, &self.address.any, self.address.getOsSockLen()) catch |err| {
|
||||
log.err("server bind error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
log.info("udp server listening on port {d}", .{self.address.getPort()});
|
||||
|
||||
var buffer: [1024]u8 = undefined;
|
||||
while (!self.signalExit.load(.monotonic)) {
|
||||
var src_addr: posix.sockaddr = undefined;
|
||||
|
||||
var src_len: posix.socklen_t = @sizeOf(posix.sockaddr);
|
||||
const n = posix.recvfrom(sockfd, buffer[0..], 0, &src_addr, &src_len) catch |err| switch (err) {
|
||||
error.WouldBlock => {
|
||||
std.Thread.sleep(20 * std.time.ns_per_ms);
|
||||
continue;
|
||||
},
|
||||
else => {
|
||||
log.err("server recv error: {any}", .{err});
|
||||
break;
|
||||
},
|
||||
};
|
||||
|
||||
const addr = std.net.Address.initPosix(@alignCast(&src_addr));
|
||||
log.info("server recv {d} bytes from {f}", .{ n, addr });
|
||||
|
||||
const msg = buffer[0..n];
|
||||
if (std.mem.eql(u8, msg, "HELLO")) {
|
||||
_ = posix.sendto(sockfd, "HELLO", 0, &src_addr, src_len) catch |err| {
|
||||
log.err("server send error: {any}", .{err});
|
||||
continue;
|
||||
};
|
||||
log.info("server replied HELLO", .{});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn startClient(self: *@This()) void {
|
||||
const sockfd = posix.socket(
|
||||
self.address.any.family,
|
||||
posix.SOCK.DGRAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK,
|
||||
posix.IPPROTO.UDP,
|
||||
) catch |err| {
|
||||
log.err("client socket error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
defer posix.close(sockfd);
|
||||
|
||||
const local_addr = std.net.Address.parseIp4("0.0.0.0", 0) catch |err| {
|
||||
log.err("client local addr parse error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
posix.bind(sockfd, &local_addr.any, local_addr.getOsSockLen()) catch |err| {
|
||||
log.err("client bind error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
_ = posix.sendto(sockfd, "HELLO", 0, &self.address.any, self.address.getOsSockLen()) catch |err| {
|
||||
log.err("client send error: {any}", .{err});
|
||||
return;
|
||||
};
|
||||
log.info("client sent HELLO to {f}", .{self.address});
|
||||
|
||||
var buffer: [1024]u8 = undefined;
|
||||
while (!self.signalExit.load(.monotonic)) {
|
||||
var src_addr: posix.sockaddr = undefined;
|
||||
var src_len: posix.socklen_t = @sizeOf(posix.sockaddr);
|
||||
const n = posix.recvfrom(sockfd, buffer[0..], 0, &src_addr, &src_len) catch |err| switch (err) {
|
||||
error.WouldBlock => {
|
||||
std.Thread.sleep(20 * std.time.ns_per_ms);
|
||||
continue;
|
||||
},
|
||||
else => {
|
||||
log.err("client recv error: {any}", .{err});
|
||||
break;
|
||||
},
|
||||
};
|
||||
|
||||
const msg = buffer[0..n];
|
||||
log.info("client recv {d} bytes", .{n});
|
||||
if (std.mem.eql(u8, msg, "HELLO")) {
|
||||
log.info("client received HELLO reply", .{});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shouldExit(self: *@This()) bool {
|
||||
return self.signalExit.load(.monotonic);
|
||||
}
|
||||
|
||||
pub fn destroy(self: *@This()) void {
|
||||
self.allocator.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const log = @import("logging.zig").log;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
const std = @import("std");
|
||||
const transport = @import("crasa-transport");
|
||||
const zargs = @import("zargs");
|
||||
const log = transport.logging.log;
|
||||
|
||||
const Config = struct {
|
||||
host: []const u8 = "127.0.0.1",
|
||||
port: u16 = 9700,
|
||||
|
||||
pub const meta = .{
|
||||
.port = .{
|
||||
.short = 'p',
|
||||
.help = "UDP port to connect on",
|
||||
},
|
||||
.host = .{
|
||||
.short = 'H',
|
||||
.help = "Hostname to resolve",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
zargs.setAllocator(allocator);
|
||||
defer zargs.shutdown();
|
||||
|
||||
const config: Config = try zargs.parse(Config);
|
||||
const argv = try std.process.argsAlloc(allocator);
|
||||
defer std.process.argsFree(allocator, argv);
|
||||
|
||||
if (zargs.isHelp()) {
|
||||
const help_text = try zargs.getUsageAlloc("test-client");
|
||||
defer allocator.free(help_text);
|
||||
std.debug.print("{s}", .{help_text});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("client startup argc={d} port={d}", .{ argv.len, config.port });
|
||||
for (argv, 0..) |arg, i| {
|
||||
log.info("client argv[{d}]={s}", .{ i, arg });
|
||||
}
|
||||
|
||||
const engine = try transport.UdpEngine.create(allocator);
|
||||
defer engine.destroy();
|
||||
|
||||
try engine.connect(config.host, config.port);
|
||||
|
||||
while (!engine.shouldExit()) {
|
||||
std.Thread.sleep(500 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
const std = @import("std");
|
||||
const transport = @import("crasa-transport");
|
||||
const zargs = @import("zargs");
|
||||
const log = transport.logging.log;
|
||||
|
||||
const Config = struct {
|
||||
port: u16 = 9700,
|
||||
|
||||
pub const meta = .{
|
||||
.port = .{
|
||||
.short = 'p',
|
||||
.help = "UDP port",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
zargs.setAllocator(allocator);
|
||||
defer zargs.shutdown();
|
||||
|
||||
const config = try zargs.parse(Config);
|
||||
const argv = try std.process.argsAlloc(allocator);
|
||||
defer std.process.argsFree(allocator, argv);
|
||||
|
||||
if (zargs.isHelp()) {
|
||||
const help_text = try zargs.getUsageAlloc("test-server");
|
||||
defer allocator.free(help_text);
|
||||
std.debug.print("{s}", .{help_text});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("server startup argc={d} port={d}", .{ argv.len, config.port });
|
||||
for (argv, 0..) |arg, i| {
|
||||
log.info("server argv[{d}]={s}", .{ i, arg });
|
||||
}
|
||||
|
||||
const engine = try transport.UdpEngine.create(allocator);
|
||||
defer engine.destroy();
|
||||
try engine.serve(config.port);
|
||||
|
||||
while (!engine.shouldExit()) {
|
||||
std.Thread.sleep(500 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package main
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
module crasad/transport/webtest
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.865
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A=
|
||||
github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*websocket.Conn]struct{}
|
||||
history []logMessage
|
||||
maxHistory int
|
||||
}
|
||||
|
||||
func newHub() *hub {
|
||||
return &hub{
|
||||
clients: make(map[*websocket.Conn]struct{}),
|
||||
history: make([]logMessage, 0, 512),
|
||||
maxHistory: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) add(c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
snapshot := append([]logMessage(nil), h.history...)
|
||||
clientCount := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
|
||||
log.Printf("ws client connected remote=%s clients=%d", c.RemoteAddr(), clientCount)
|
||||
h.replay(c, snapshot)
|
||||
}
|
||||
|
||||
func (h *hub) remove(c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.clients, c)
|
||||
log.Printf("ws client disconnected remote=%s clients=%d", c.RemoteAddr(), len(h.clients))
|
||||
}
|
||||
|
||||
func (h *hub) broadcast(msg logMessage) {
|
||||
log.Printf("message process=%s stream=%s line=%q time=%s", msg.Process, msg.Stream, msg.Line, msg.Time)
|
||||
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("broadcast marshal error process=%s stream=%s err=%v", msg.Process, msg.Stream, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.history = append(h.history, msg)
|
||||
if len(h.history) > h.maxHistory {
|
||||
h.history = h.history[len(h.history)-h.maxHistory:]
|
||||
}
|
||||
|
||||
for c := range h.clients {
|
||||
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
log.Printf("broadcast write error remote=%s err=%v", c.RemoteAddr(), err)
|
||||
_ = c.Close()
|
||||
delete(h.clients, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) replay(c *websocket.Conn, snapshot []logMessage) {
|
||||
for _, msg := range snapshot {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
log.Printf("ws replay failed remote=%s err=%v", c.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("ws replay complete remote=%s messages=%d", c.RemoteAddr(), len(snapshot))
|
||||
}
|
||||
|
||||
func (h *hub) status(process, line string) {
|
||||
log.Printf("status process=%s line=%s", process, line)
|
||||
h.broadcast(logMessage{
|
||||
Process: process,
|
||||
Stream: "status",
|
||||
Line: line,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if runtime.GOOS == "windows" {
|
||||
log.SetFlags(log.LstdFlags)
|
||||
}
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetPrefix("webtest ")
|
||||
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
cwd,
|
||||
filepath.Clean(filepath.Join(cwd, "..")),
|
||||
}
|
||||
log.Printf("repo root discovery cwd=%s candidates=%v", cwd, candidates)
|
||||
|
||||
for _, dir := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(dir, "build.zig")); err == nil {
|
||||
log.Printf("repo root found path=%s", dir)
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find repo root with build.zig from %s", cwd)
|
||||
}
|
||||
|
||||
func runBuildInstall(root string, h *hub) error {
|
||||
start := time.Now()
|
||||
cmd := exec.Command("zig", "build", "install")
|
||||
cmd.Dir = root
|
||||
log.Printf("build start cmd=%q dir=%s", strings.Join(cmd.Args, " "), root)
|
||||
h.status("build", "running zig build install")
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go streamPipe("build", "stdout", stdout, h, &wg)
|
||||
go streamPipe("build", "stderr", stderr, h, &wg)
|
||||
wg.Wait()
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("build failed duration=%s err=%v", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
log.Printf("build success duration=%s", time.Since(start))
|
||||
h.status("build", "zig build install completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamPipe(procName, streamName string, r io.Reader, h *hub, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
h.broadcast(logMessage{
|
||||
Process: procName,
|
||||
Stream: streamName,
|
||||
Line: scanner.Text(),
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Printf("stream scanner error process=%s stream=%s err=%v", procName, streamName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func launchProcess(ctx context.Context, root, name, binPath string, args []string, h *hub) (*exec.Cmd, error) {
|
||||
log.Printf("launch requested name=%s bin=%s args=%v dir=%s", name, binPath, args, root)
|
||||
cmd := exec.CommandContext(ctx, binPath, args...)
|
||||
cmd.Dir = root
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.status(name, fmt.Sprintf("started pid=%d args=%v", cmd.Process.Pid, args))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go streamPipe(name, "stdout", stdout, h, &wg)
|
||||
go streamPipe(name, "stderr", stderr, h, &wg)
|
||||
go func() { wg.Wait() }()
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func stopProcess(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = cmd.Process.Kill()
|
||||
return
|
||||
}
|
||||
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = cmd.Process.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func mustBinPath(root, name string) (string, error) {
|
||||
suffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
suffix = ".exe"
|
||||
}
|
||||
|
||||
p := filepath.Join(root, "zig-out", "bin", name+suffix)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return "", fmt.Errorf("missing binary %s: %w", p, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func run() error {
|
||||
log.Printf("startup orchestrator pid=%d", os.Getpid())
|
||||
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h := newHub()
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/", templ.Handler(indexPage()).ServeHTTP)
|
||||
|
||||
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade failed remote=%s err=%v", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
h.add(conn)
|
||||
defer func() {
|
||||
h.remove(conn)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
staticServer := http.FileServer(http.FS(staticFiles))
|
||||
r.Handle("/static/*", http.StripPrefix("/", staticServer))
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: "127.0.0.1:5050",
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("http server listen addr=http://127.0.0.1:5050")
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := runBuildInstall(root, h); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverBin, err := mustBinPath(root, "test-server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clientBin, err := mustBinPath(root, "test-client")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverCmd, err := launchProcess(ctx, root, "server", serverBin, []string{"--port", "5000"}, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
clientMu sync.Mutex
|
||||
nextClientID = 1
|
||||
clients = map[int]*exec.Cmd{}
|
||||
)
|
||||
|
||||
launchClient := func() (int, error) {
|
||||
clientMu.Lock()
|
||||
id := nextClientID
|
||||
nextClientID += 1
|
||||
clientMu.Unlock()
|
||||
|
||||
name := fmt.Sprintf("client-%d", id)
|
||||
cmd, err := launchProcess(ctx, root, name, clientBin, []string{"--port=5000", "--host=localhost"}, h)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
clientMu.Lock()
|
||||
clients[id] = cmd
|
||||
clientMu.Unlock()
|
||||
|
||||
go func(clientID int, clientName string, clientCmd *exec.Cmd) {
|
||||
err := clientCmd.Wait()
|
||||
clientMu.Lock()
|
||||
delete(clients, clientID)
|
||||
clientMu.Unlock()
|
||||
if err != nil {
|
||||
h.status("orchestrator", fmt.Sprintf("%s exited with error: %v", clientName, err))
|
||||
return
|
||||
}
|
||||
h.status("orchestrator", fmt.Sprintf("%s exited cleanly", clientName))
|
||||
}(id, name, cmd)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
stopClient := func(id int) bool {
|
||||
clientMu.Lock()
|
||||
cmd, ok := clients[id]
|
||||
if ok {
|
||||
delete(clients, id)
|
||||
}
|
||||
clientMu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
stopProcess(cmd)
|
||||
return true
|
||||
}
|
||||
|
||||
stopAllClients := func() {
|
||||
clientMu.Lock()
|
||||
copied := make([]*exec.Cmd, 0, len(clients))
|
||||
for _, cmd := range clients {
|
||||
copied = append(copied, cmd)
|
||||
}
|
||||
clients = map[int]*exec.Cmd{}
|
||||
clientMu.Unlock()
|
||||
for _, cmd := range copied {
|
||||
stopProcess(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
r.Post("/api/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := launchClient()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := fmt.Sprintf("client-%d", id)
|
||||
h.status("orchestrator", fmt.Sprintf("spawned %s", name))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": id,
|
||||
"name": name,
|
||||
})
|
||||
})
|
||||
|
||||
r.Delete("/api/clients/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
idText := chi.URLParam(r, "id")
|
||||
id, err := strconv.Atoi(idText)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid client id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !stopClient(id) {
|
||||
http.Error(w, "client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
h.status("orchestrator", fmt.Sprintf("stopped client-%d", id))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if _, err := launchClient(); err != nil {
|
||||
stopProcess(serverCmd)
|
||||
return err
|
||||
}
|
||||
|
||||
h.status("orchestrator", "server and initial client launched")
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
waitCh := make(chan string, 1)
|
||||
go func() {
|
||||
if err := serverCmd.Wait(); err != nil {
|
||||
waitCh <- "server exited with error: " + err.Error()
|
||||
return
|
||||
}
|
||||
waitCh <- "server exited cleanly"
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
h.status("orchestrator", "signal: "+sig.String())
|
||||
goto shutdown
|
||||
case line := <-waitCh:
|
||||
h.status("orchestrator", line)
|
||||
h.status("orchestrator", "server exited; orchestrator staying alive until interrupted")
|
||||
}
|
||||
}
|
||||
|
||||
shutdown:
|
||||
cancel()
|
||||
stopProcess(serverCmd)
|
||||
stopAllClients()
|
||||
h.status("orchestrator", "shutdown complete")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = httpServer.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
(() => {
|
||||
const ws = new WebSocket(`ws://${location.host}/ws`);
|
||||
|
||||
const clientTabs = document.getElementById("client-tabs");
|
||||
const clientPanels = document.getElementById("client-panels");
|
||||
const cardTemplate = document.getElementById("client-card-template");
|
||||
const tabTemplate = document.getElementById("client-tab-template");
|
||||
const addClientBtn = document.getElementById("add-client");
|
||||
const leftTabServer = document.getElementById("left-tab-server");
|
||||
const leftTabSystem = document.getElementById("left-tab-system");
|
||||
const leftPaneServer = document.getElementById("left-pane-server");
|
||||
const leftPaneSystem = document.getElementById("left-pane-system");
|
||||
const clients = new Map();
|
||||
const closedTabs = new Set();
|
||||
let activeClient = null;
|
||||
|
||||
function append(target, text, stream, severity) {
|
||||
const pane = document.getElementById(target);
|
||||
if (!pane) return;
|
||||
const line = document.createElement("div");
|
||||
line.className = [stream || "stdout", severity].filter(Boolean).join(" ");
|
||||
line.textContent = text;
|
||||
pane.appendChild(line);
|
||||
pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function appendStatus(text) {
|
||||
append("system-log", text, "status");
|
||||
}
|
||||
|
||||
function setLeftTab(name) {
|
||||
const serverActive = name === "server";
|
||||
leftTabServer.classList.toggle("active", serverActive);
|
||||
leftTabSystem.classList.toggle("active", !serverActive);
|
||||
leftPaneServer.hidden = !serverActive;
|
||||
leftPaneSystem.hidden = serverActive;
|
||||
}
|
||||
|
||||
function parseClientId(process) {
|
||||
if (!process.startsWith("client-")) return null;
|
||||
const raw = process.slice("client-".length);
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
async function closeClient(process) {
|
||||
const id = parseClientId(process);
|
||||
if (id == null) return;
|
||||
await fetch(`/api/clients/${id}`, { method: "DELETE" });
|
||||
removeClient(process);
|
||||
closedTabs.add(process);
|
||||
}
|
||||
|
||||
function setActiveClient(process) {
|
||||
activeClient = process;
|
||||
for (const [name, meta] of clients.entries()) {
|
||||
const active = name === process;
|
||||
meta.tab.classList.toggle("active", active);
|
||||
meta.panel.classList.toggle("active", active);
|
||||
meta.panel.hidden = !active;
|
||||
}
|
||||
}
|
||||
|
||||
function removeClient(process) {
|
||||
const meta = clients.get(process);
|
||||
if (!meta) return;
|
||||
meta.tab.remove();
|
||||
meta.panel.remove();
|
||||
clients.delete(process);
|
||||
|
||||
if (activeClient === process) {
|
||||
const next = clients.keys().next();
|
||||
if (!next.done) setActiveClient(next.value);
|
||||
else activeClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(process) {
|
||||
const meta = clients.get(process);
|
||||
if (!meta) return;
|
||||
meta.tab.remove();
|
||||
meta.panel.remove();
|
||||
clients.delete(process);
|
||||
closedTabs.add(process);
|
||||
|
||||
if (activeClient === process) {
|
||||
const next = clients.keys().next();
|
||||
if (!next.done) setActiveClient(next.value);
|
||||
else activeClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureClient(process) {
|
||||
if (closedTabs.has(process)) return;
|
||||
if (clients.has(process)) return;
|
||||
|
||||
const tabClone = tabTemplate.content.cloneNode(true);
|
||||
const tab = tabClone.querySelector(".client-tab");
|
||||
const tabSelect = tabClone.querySelector(".client-tab-select");
|
||||
tabSelect.textContent = process;
|
||||
|
||||
const cardClone = cardTemplate.content.cloneNode(true);
|
||||
const panel = cardClone.querySelector(".client-card");
|
||||
const name = cardClone.querySelector(".client-name");
|
||||
const stdout = cardClone.querySelector(".client-stdout");
|
||||
const stderr = cardClone.querySelector(".client-stderr");
|
||||
const closeTabBtn = cardClone.querySelector(".btn-close-tab");
|
||||
const killBtn = cardClone.querySelector(".btn-kill");
|
||||
|
||||
name.textContent = process;
|
||||
stdout.id = `${process}-stdout`;
|
||||
stderr.id = `${process}-stderr`;
|
||||
panel.hidden = true;
|
||||
|
||||
tabSelect.addEventListener("click", () => setActiveClient(process));
|
||||
closeTabBtn.addEventListener("click", async () => closeClient(process));
|
||||
killBtn.addEventListener("click", async () => closeClient(process));
|
||||
|
||||
clientTabs.appendChild(tabClone);
|
||||
clientPanels.appendChild(cardClone);
|
||||
clients.set(process, { tab, panel });
|
||||
|
||||
if (!activeClient) {
|
||||
setActiveClient(process);
|
||||
}
|
||||
}
|
||||
|
||||
function appendToProcess(process, stream, text) {
|
||||
const safeStream = stream === "stderr" ? "stderr" : "stdout";
|
||||
let severity = "";
|
||||
if (safeStream === "stderr") {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.startsWith("[") && lower.includes("] ")) {
|
||||
const idx = lower.indexOf("] ");
|
||||
const raw = lower.slice(idx + 2).trimStart();
|
||||
if (raw.startsWith("error")) severity = "stderr-error";
|
||||
else if (raw.startsWith("warning")) severity = "stderr-warning";
|
||||
else severity = "stderr-default";
|
||||
} else if (lower.startsWith("error")) {
|
||||
severity = "stderr-error";
|
||||
} else if (lower.startsWith("warning")) {
|
||||
severity = "stderr-warning";
|
||||
} else {
|
||||
severity = "stderr-default";
|
||||
}
|
||||
}
|
||||
append(`${process}-${safeStream}`, text, stream, severity);
|
||||
}
|
||||
|
||||
addClientBtn.addEventListener("click", async () => {
|
||||
const res = await fetch("/api/clients", { method: "POST" });
|
||||
if (!res.ok) return;
|
||||
const payload = await res.json();
|
||||
if (payload && payload.name) {
|
||||
closedTabs.delete(payload.name);
|
||||
ensureClient(payload.name);
|
||||
setActiveClient(payload.name);
|
||||
}
|
||||
});
|
||||
|
||||
leftTabServer.addEventListener("click", () => setLeftTab("server"));
|
||||
leftTabSystem.addEventListener("click", () => setLeftTab("system"));
|
||||
setLeftTab("server");
|
||||
|
||||
ws.onopen = () => {
|
||||
appendStatus("[orchestrator/status] websocket connected");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
const line = `[${msg.process}/${msg.stream}] ${msg.line}`;
|
||||
if (msg.process === "server") {
|
||||
appendToProcess("server", msg.stream, line);
|
||||
return;
|
||||
}
|
||||
if (msg.process.startsWith("client-")) {
|
||||
closedTabs.delete(msg.process);
|
||||
ensureClient(msg.process);
|
||||
appendToProcess(msg.process, msg.stream, line);
|
||||
return;
|
||||
}
|
||||
appendStatus(line);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
appendStatus("[orchestrator/status] websocket disconnected");
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/* Vendored local Tailwind-style utility subset for webtest. */
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.min-h-screen { min-height: 100vh; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.max-w-7xl { max-width: 80rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.md\:p-6 { padding: 1rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.tracking-tight { letter-spacing: -0.025em; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.h-\[80vh\] { height: 80vh; }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
.min-h-0 { min-height: 0; }
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
|
||||
.bg-slate-950 { background-color: #020617; }
|
||||
.bg-slate-900 { background-color: #0f172a; }
|
||||
.text-slate-100 { color: #f1f5f9; }
|
||||
.border-slate-700 { border-color: #334155; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clients-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pane-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pane-tab {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
padding: 0.28rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pane-tab.active {
|
||||
background: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.client-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.client-tab-select {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
padding: 0.28rem 0.65rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-tab.active .client-tab-select {
|
||||
background: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.client-panels {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-card[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.client-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.btn-close-tab {
|
||||
border: 1px solid #475569;
|
||||
border-radius: 0.45rem;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
padding: 0.2rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-kill {
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 0.45rem;
|
||||
background: #991b1b;
|
||||
color: #fee2e2;
|
||||
padding: 0.2rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stdout { color: #a5d6ff; }
|
||||
.stderr { color: #cbd5e1; }
|
||||
.status { color: #7ee787; }
|
||||
.stderr-default { color: #cbd5e1; }
|
||||
.stderr-warning { color: #facc15; background: rgba(250, 204, 21, 0.12); }
|
||||
.stderr-error { color: #f87171; background: rgba(248, 113, 113, 0.12); }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.layout { grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.1fr); }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.md\:p-6 { padding: 1.5rem; }
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
func indexPage() templ.Component {
|
||||
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||
_, err := io.WriteString(w, `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Transport WebTest</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.css" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<main class="mx-auto max-w-7xl p-4 md:p-6">
|
||||
<h1 class="mb-4 text-xl font-semibold tracking-tight">Transport WebTest</h1>
|
||||
<div class="layout h-[80vh]">
|
||||
<section class="flex min-h-0 flex-col rounded-lg border border-slate-700 bg-slate-900">
|
||||
<header class="pane-tabs border-b border-slate-700 px-3 py-2">
|
||||
<button id="left-tab-server" class="pane-tab active" type="button">Server</button>
|
||||
<button id="left-tab-system" class="pane-tab" type="button">System</button>
|
||||
</header>
|
||||
<div id="left-pane-server" class="grid flex-1 min-h-0 grid-cols-1 gap-3 p-3">
|
||||
<div class="flex min-h-0 flex-col rounded-lg border border-slate-700">
|
||||
<div class="border-b border-slate-700 px-3 py-2 text-sm font-medium">stdout</div>
|
||||
<div id="server-stdout" class="flex-1 overflow-auto p-3 text-sm"></div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-col rounded-lg border border-slate-700">
|
||||
<div class="border-b border-slate-700 px-3 py-2 text-sm font-medium">stderr</div>
|
||||
<div id="server-stderr" class="flex-1 overflow-auto p-3 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="left-pane-system" class="flex-1 min-h-0 p-3" hidden>
|
||||
<div class="flex h-full min-h-0 flex-col rounded-lg border border-slate-700">
|
||||
<div class="border-b border-slate-700 px-3 py-2 text-sm font-medium">orchestrator/build</div>
|
||||
<div id="system-log" class="flex-1 overflow-auto p-3 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex min-h-0 flex-col rounded-lg border border-slate-700 bg-slate-900">
|
||||
<header class="clients-header border-b border-slate-700 px-3 py-2">
|
||||
<span class="font-medium">clients</span>
|
||||
<button id="add-client" class="btn-add" type="button">+ add client</button>
|
||||
</header>
|
||||
<div id="client-tabs" class="client-tabs"></div>
|
||||
<div id="client-panels" class="client-panels"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<template id="client-card-template">
|
||||
<section class="client-card rounded-lg border border-slate-700 bg-slate-900">
|
||||
<header class="client-card-header border-b border-slate-700 px-3 py-2">
|
||||
<span class="client-name font-medium"></span>
|
||||
<div class="client-actions">
|
||||
<button class="btn-close-tab" type="button">Close Tab</button>
|
||||
<button class="btn-kill" type="button">Kill</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid flex-1 min-h-0 grid-cols-1 gap-3 p-3">
|
||||
<div class="flex min-h-0 flex-col rounded-lg border border-slate-700">
|
||||
<div class="border-b border-slate-700 px-3 py-2 text-sm font-medium">stdout</div>
|
||||
<div class="client-stdout flex-1 overflow-auto p-3 text-sm"></div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-col rounded-lg border border-slate-700">
|
||||
<div class="border-b border-slate-700 px-3 py-2 text-sm font-medium">stderr</div>
|
||||
<div class="client-stderr flex-1 overflow-auto p-3 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template id="client-tab-template">
|
||||
<div class="client-tab">
|
||||
<button class="client-tab-select" type="button"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>`)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package main
|
||||
|
||||
type logMessage struct {
|
||||
Process string `json:"process"`
|
||||
Stream string `json:"stream"`
|
||||
Line string `json:"line"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
Loading…
Reference in New Issue