feat(portal): show external runners
This commit is contained in:
parent
a17122a907
commit
58435c41e1
@ -160,6 +160,23 @@ type CoordinatorRunEventsResponse struct {
|
||||
Events []CoordinatorRunEvent `json:"events"`
|
||||
}
|
||||
|
||||
type CoordinatorExternalRunner struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Workflow string `json:"workflow,omitempty"`
|
||||
Job string `json:"job,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Created string `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorExternalRunnerSyncResponse struct {
|
||||
Runners []CoordinatorExternalRunner `json:"runners"`
|
||||
Stale []CoordinatorExternalRunner `json:"stale"`
|
||||
}
|
||||
|
||||
type CoordinatorRunEventResponse struct {
|
||||
Event CoordinatorRunEvent `json:"event"`
|
||||
}
|
||||
@ -599,6 +616,15 @@ func (c *CoordinatorClient) AppendRunEvent(ctx context.Context, runID string, in
|
||||
return res.Event, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) SyncExternalRunners(ctx context.Context, provider string, runners []CoordinatorExternalRunner) (CoordinatorExternalRunnerSyncResponse, error) {
|
||||
var res CoordinatorExternalRunnerSyncResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/runners/sync", map[string]any{
|
||||
"provider": provider,
|
||||
"runners": runners,
|
||||
}, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) RunEvents(ctx context.Context, runID string, after, limit int) ([]CoordinatorRunEvent, error) {
|
||||
var res CoordinatorRunEventsResponse
|
||||
values := url.Values{}
|
||||
|
||||
@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -44,6 +45,7 @@ func (a App) list(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
|
||||
if *jsonOut {
|
||||
if jsonBackend, ok := backend.(JSONListBackend); ok {
|
||||
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
|
||||
@ -58,6 +60,51 @@ func (a App) list(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) syncExternalRunnersBestEffort(ctx context.Context, cfg Config, backend Backend) {
|
||||
if !isBlacksmithProvider(cfg.Provider) {
|
||||
return
|
||||
}
|
||||
client, ok, err := newCoordinatorClient(cfg)
|
||||
if err != nil || !ok {
|
||||
return
|
||||
}
|
||||
jsonBackend, ok := backend.(JSONListBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
|
||||
return
|
||||
}
|
||||
runners, err := coordinatorExternalRunnersFromListView(view)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
|
||||
return
|
||||
}
|
||||
if _, err := client.SyncExternalRunners(ctx, "blacksmith-testbox", runners); err != nil {
|
||||
fmt.Fprintf(a.Stderr, "warning: external runner portal sync failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func coordinatorExternalRunnersFromListView(view any) ([]CoordinatorExternalRunner, error) {
|
||||
data, err := json.Marshal(view)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var runners []CoordinatorExternalRunner
|
||||
if err := json.Unmarshal(data, &runners); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range runners {
|
||||
runners[i].Provider = "blacksmith-testbox"
|
||||
if runners[i].CreatedAt == "" {
|
||||
runners[i].CreatedAt = runners[i].Created
|
||||
}
|
||||
}
|
||||
return runners, nil
|
||||
}
|
||||
|
||||
func activeCoordinatorLeaseIDs(leases []CoordinatorLease) map[string]struct{} {
|
||||
ids := make(map[string]struct{}, len(leases))
|
||||
for _, lease := range leases {
|
||||
|
||||
@ -110,6 +110,35 @@ func TestCoordinatorMachineOrphanField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorExternalRunnersFromBlacksmithListView(t *testing.T) {
|
||||
view := []map[string]string{
|
||||
{
|
||||
"id": "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
||||
"status": "ready",
|
||||
"repo": "openclaw",
|
||||
"workflow": ".github/workflows/ci-check-testbox.yml",
|
||||
"job": "check",
|
||||
"ref": "main",
|
||||
"created": "2026-05-06T09:45:16.000000Z",
|
||||
},
|
||||
}
|
||||
|
||||
runners, err := coordinatorExternalRunnersFromListView(view)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runners) != 1 {
|
||||
t.Fatalf("len=%d, want 1", len(runners))
|
||||
}
|
||||
got := runners[0]
|
||||
if got.Provider != "blacksmith-testbox" {
|
||||
t.Fatalf("provider=%q", got.Provider)
|
||||
}
|
||||
if got.ID != "tbx_01kqyahxh67z6qtwtsdkt5xcst" || got.CreatedAt != "2026-05-06T09:45:16.000000Z" {
|
||||
t.Fatalf("unexpected runner: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatInterval(t *testing.T) {
|
||||
tests := map[time.Duration]time.Duration{
|
||||
0: time.Minute,
|
||||
|
||||
@ -23,6 +23,9 @@ import {
|
||||
} from "./tailscale";
|
||||
import type {
|
||||
Env,
|
||||
ExternalRunnerInput,
|
||||
ExternalRunnerRecord,
|
||||
ExternalRunnerSyncRequest,
|
||||
LeaseRecord,
|
||||
LeaseRequest,
|
||||
LeaseTelemetry,
|
||||
@ -49,6 +52,7 @@ const maxStoredRunLogBytes = 8 * 1024 * 1024;
|
||||
const runLogChunkBytes = 64 * 1024;
|
||||
const maxLeaseTelemetryHistory = 60;
|
||||
const maxRunTelemetrySamples = 60;
|
||||
const maxExternalRunnerSyncItems = 200;
|
||||
const webVNCTicketTTLSeconds = 120;
|
||||
const codeTicketTTLSeconds = 120;
|
||||
const maxPendingWebVNCBytes = 1024 * 1024;
|
||||
@ -211,6 +215,12 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (method === "GET" && parts.join("/") === "v1/runs") {
|
||||
return await this.listRuns(request);
|
||||
}
|
||||
if (method === "GET" && parts.join("/") === "v1/runners") {
|
||||
return await this.listExternalRunners(request);
|
||||
}
|
||||
if (method === "POST" && parts.join("/") === "v1/runners/sync") {
|
||||
return await this.syncExternalRunners(request);
|
||||
}
|
||||
if (method === "POST" && parts.join("/") === "v1/runs") {
|
||||
return await this.createRun(request);
|
||||
}
|
||||
@ -662,7 +672,11 @@ export class FleetDurableObject implements DurableObject {
|
||||
private async portalRoute(request: Request, parts: string[]): Promise<Response> {
|
||||
const method = request.method.toUpperCase();
|
||||
if (method === "GET" && parts.length === 1) {
|
||||
return portalHome(await this.portalLeases(request), request);
|
||||
const [leases, runners] = await Promise.all([
|
||||
this.portalLeases(request),
|
||||
this.visibleExternalRunners(request),
|
||||
]);
|
||||
return portalHome(leases, runners, request);
|
||||
}
|
||||
if (method === "GET" && parts[1] === "runs" && parts[2]) {
|
||||
return await this.portalRunRoute(request, parts[2], parts[3]);
|
||||
@ -1638,6 +1652,97 @@ export class FleetDurableObject implements DurableObject {
|
||||
});
|
||||
}
|
||||
|
||||
private async listExternalRunners(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const provider = url.searchParams.get("provider") ?? "";
|
||||
const status = url.searchParams.get("status") ?? "";
|
||||
const stale = url.searchParams.get("stale") ?? "";
|
||||
const limit = clampLimit(url.searchParams.get("limit"), 100);
|
||||
return json({
|
||||
runners: (await this.visibleExternalRunners(request))
|
||||
.filter((runner) => !provider || runner.provider === provider)
|
||||
.filter((runner) => !status || runner.status === status)
|
||||
.filter((runner) => {
|
||||
if (stale === "true") {
|
||||
return runner.stale === true;
|
||||
}
|
||||
if (stale === "false") {
|
||||
return runner.stale !== true;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toSorted((a, b) => runnerSortTime(b).localeCompare(runnerSortTime(a)))
|
||||
.slice(0, limit),
|
||||
});
|
||||
}
|
||||
|
||||
private async syncExternalRunners(request: Request): Promise<Response> {
|
||||
const owner = requestOwner(request);
|
||||
const org = requestOrg(request, this.env);
|
||||
const input = await readJson<ExternalRunnerSyncRequest>(request);
|
||||
const provider = sanitizeRunnerProvider(input.provider);
|
||||
if (!provider) {
|
||||
return json({ error: "invalid_provider" }, { status: 400 });
|
||||
}
|
||||
const rawRunners = Array.isArray(input.runners) ? input.runners : [];
|
||||
if (rawRunners.length > maxExternalRunnerSyncItems) {
|
||||
return json({ error: "too_many_runners" }, { status: 400 });
|
||||
}
|
||||
const now = new Date();
|
||||
const nowISO = now.toISOString();
|
||||
const existing = await this.externalRunnerRecords();
|
||||
const seenIDs = new Set<string>();
|
||||
const synced: ExternalRunnerRecord[] = [];
|
||||
for (const raw of rawRunners) {
|
||||
const sanitized = sanitizeExternalRunner(raw, provider, now);
|
||||
if (!sanitized || seenIDs.has(sanitized.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIDs.add(sanitized.id);
|
||||
const previous = existing.find(
|
||||
(runner) =>
|
||||
runner.provider === provider &&
|
||||
runner.id === sanitized.id &&
|
||||
runner.owner === owner &&
|
||||
runner.org === org,
|
||||
);
|
||||
const runner: ExternalRunnerRecord = {
|
||||
...previous,
|
||||
...sanitized,
|
||||
owner,
|
||||
org,
|
||||
provider,
|
||||
firstSeenAt: previous?.firstSeenAt || nowISO,
|
||||
lastSeenAt: nowISO,
|
||||
updatedAt: nowISO,
|
||||
};
|
||||
delete runner.stale;
|
||||
await this.putExternalRunner(runner);
|
||||
synced.push(runner);
|
||||
}
|
||||
const stale: ExternalRunnerRecord[] = [];
|
||||
for (const runner of existing) {
|
||||
if (
|
||||
runner.provider !== provider ||
|
||||
runner.owner !== owner ||
|
||||
runner.org !== org ||
|
||||
seenIDs.has(runner.id) ||
|
||||
runner.stale
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const next: ExternalRunnerRecord = {
|
||||
...runner,
|
||||
status: "missing",
|
||||
stale: true,
|
||||
updatedAt: nowISO,
|
||||
};
|
||||
await this.putExternalRunner(next);
|
||||
stale.push(next);
|
||||
}
|
||||
return json({ runners: synced, stale });
|
||||
}
|
||||
|
||||
private async usage(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const requestedScope = url.searchParams.get("scope") ?? "user";
|
||||
@ -1792,6 +1897,21 @@ export class FleetDurableObject implements DurableObject {
|
||||
return [...runs.values()];
|
||||
}
|
||||
|
||||
private async externalRunnerRecords(): Promise<ExternalRunnerRecord[]> {
|
||||
const runners = await this.state.storage.list<ExternalRunnerRecord>({
|
||||
prefix: externalRunnerPrefix(),
|
||||
});
|
||||
return [...runners.values()];
|
||||
}
|
||||
|
||||
private async visibleExternalRunners(request: Request): Promise<ExternalRunnerRecord[]> {
|
||||
const runners = await this.externalRunnerRecords();
|
||||
const admin = isAdminRequest(request);
|
||||
const owner = requestOwner(request);
|
||||
const org = requestOrg(request, this.env);
|
||||
return runners.filter((runner) => admin || (runner.owner === owner && runner.org === org));
|
||||
}
|
||||
|
||||
private async runEvents(runID: string, after = 0, limit = 500): Promise<RunEventRecord[]> {
|
||||
const events = await this.state.storage.list<RunEventRecord>({
|
||||
prefix: runEventPrefix(runID),
|
||||
@ -1840,6 +1960,13 @@ export class FleetDurableObject implements DurableObject {
|
||||
await this.state.storage.put(runKey(run.id), run);
|
||||
}
|
||||
|
||||
private async putExternalRunner(runner: ExternalRunnerRecord): Promise<void> {
|
||||
await this.state.storage.put(
|
||||
externalRunnerKey(runner.provider, runner.id, runner.owner, runner.org),
|
||||
runner,
|
||||
);
|
||||
}
|
||||
|
||||
private async appendRunEventRecord(
|
||||
run: RunRecord,
|
||||
input: RunEventRequest,
|
||||
@ -1908,6 +2035,14 @@ function runKey(runID: string): string {
|
||||
return `run:${runID}`;
|
||||
}
|
||||
|
||||
function externalRunnerPrefix(): string {
|
||||
return "runner:";
|
||||
}
|
||||
|
||||
function externalRunnerKey(provider: string, runnerID: string, owner: string, org: string): string {
|
||||
return `${externalRunnerPrefix()}${provider}:${runnerID}:${org}:${owner}`;
|
||||
}
|
||||
|
||||
function runLogKey(runID: string): string {
|
||||
return `runlog:${runID}`;
|
||||
}
|
||||
@ -1996,6 +2131,10 @@ function validCrabboxProviderKey(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^crabbox-cbx-[a-f0-9]{12}$/.test(value);
|
||||
}
|
||||
|
||||
function validExternalRunnerID(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,128}$/.test(value);
|
||||
}
|
||||
|
||||
function clampLimit(value: string | null, fallback: number): number {
|
||||
const parsed = Number(value ?? "");
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@ -2085,6 +2224,59 @@ function nonSecretString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, 256) : "";
|
||||
}
|
||||
|
||||
function sanitizeRunnerProvider(value: unknown): string {
|
||||
const provider = nonSecretString(value).toLowerCase();
|
||||
return /^[a-z0-9][a-z0-9-]{1,63}$/.test(provider) ? provider : "";
|
||||
}
|
||||
|
||||
function sanitizeExternalRunner(
|
||||
input: ExternalRunnerInput,
|
||||
provider: string,
|
||||
now: Date,
|
||||
):
|
||||
| Omit<ExternalRunnerRecord, "owner" | "org" | "firstSeenAt" | "lastSeenAt" | "updatedAt">
|
||||
| undefined {
|
||||
const id = nonSecretString(input.id);
|
||||
if (!validExternalRunnerID(id)) {
|
||||
return undefined;
|
||||
}
|
||||
const createdAt = sanitizeRunnerTimestamp(input.createdAt, now);
|
||||
const runner: Omit<
|
||||
ExternalRunnerRecord,
|
||||
"owner" | "org" | "firstSeenAt" | "lastSeenAt" | "updatedAt"
|
||||
> = {
|
||||
id,
|
||||
provider,
|
||||
status: nonSecretString(input.status).toLowerCase() || "unknown",
|
||||
};
|
||||
for (const key of ["repo", "workflow", "job", "ref"] as const) {
|
||||
const value = nonSecretString(input[key]);
|
||||
if (value) {
|
||||
runner[key] = value;
|
||||
}
|
||||
}
|
||||
if (createdAt) {
|
||||
runner.createdAt = createdAt;
|
||||
}
|
||||
return runner;
|
||||
}
|
||||
|
||||
function sanitizeRunnerTimestamp(value: string | undefined, now: Date): string | undefined {
|
||||
const parsed = Date.parse(value ?? "");
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
if (date.getTime() > now.getTime() + 5 * 60 * 1000) {
|
||||
return undefined;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function runnerSortTime(runner: ExternalRunnerRecord): string {
|
||||
return runner.lastSeenAt || runner.updatedAt || runner.createdAt || runner.firstSeenAt;
|
||||
}
|
||||
|
||||
function webVNCLeaseError(lease: LeaseRecord): string {
|
||||
if (lease.state !== "active") {
|
||||
return "lease is not active";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LeaseRecord, RunEventRecord, RunRecord } from "./types";
|
||||
import type { ExternalRunnerRecord, LeaseRecord, RunEventRecord, RunRecord } from "./types";
|
||||
|
||||
const novncModuleURL = "/portal/assets/novnc/rfb.js";
|
||||
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
|
||||
@ -19,10 +19,18 @@ export interface PortalLeaseBridgeStatus {
|
||||
codeBridgeConnected: boolean;
|
||||
}
|
||||
|
||||
export function portalHome(leases: LeaseRecord[], request: Request): Response {
|
||||
export function portalHome(
|
||||
leases: LeaseRecord[],
|
||||
runners: ExternalRunnerRecord[],
|
||||
request: Request,
|
||||
): Response {
|
||||
const sortedLeases = leases.toSorted((a, b) => leaseSortTime(b).localeCompare(leaseSortTime(a)));
|
||||
const active = sortedLeases.filter((lease) => lease.state === "active");
|
||||
const ended = sortedLeases.length - active.length;
|
||||
const sortedRunners = runners.toSorted((a, b) =>
|
||||
runnerSortTime(b).localeCompare(runnerSortTime(a)),
|
||||
);
|
||||
const activeRunners = sortedRunners.filter((runner) => !runner.stale);
|
||||
const admin = request.headers.get("x-crabbox-admin") === "true";
|
||||
const owner = request.headers.get("x-crabbox-owner") || "";
|
||||
const org = request.headers.get("x-crabbox-org") || "";
|
||||
@ -44,9 +52,16 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
|
||||
const rows = sortedLeases.length
|
||||
? sortedLeases.map((lease) => leaseRow(lease, { admin, owner, org })).join("")
|
||||
: `<tr><td colspan="8" class="empty">no leases visible</td></tr>`;
|
||||
const runnerRows = sortedRunners.length
|
||||
? sortedRunners.map((runner) => runnerRow(runner, { admin, owner, org })).join("")
|
||||
: `<tr><td colspan="8" class="empty">no external runners synced</td></tr>`;
|
||||
const summary = admin
|
||||
? `${active.length} active / ${ended} ended / ${system} system`
|
||||
: `${active.length} active / ${ended} ended`;
|
||||
const runnerSummary =
|
||||
sortedRunners.length > 0
|
||||
? `${activeRunners.length} active / ${sortedRunners.length - activeRunners.length} stale`
|
||||
: "sync with crabbox list --provider blacksmith-testbox";
|
||||
return html(
|
||||
"Crabbox Portal",
|
||||
`<main class="portal-shell">
|
||||
@ -75,6 +90,27 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel table-panel runner-panel">
|
||||
<div class="section-head">
|
||||
<h2>external runners</h2>
|
||||
<span>${escapeHTML(runnerSummary)}</span>
|
||||
</div>
|
||||
<table class="runner-table" data-portal-table data-page-size="8" data-search-placeholder="search runners" data-filter-buttons="active:active,stale:stale,blacksmith-testbox:blacksmith,all:all" data-filter-default="${activeRunners.length > 0 ? "active" : "all"}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>runner</th>
|
||||
<th>status</th>
|
||||
<th>provider</th>
|
||||
<th>repo</th>
|
||||
<th>workflow</th>
|
||||
<th>job/ref</th>
|
||||
<th>seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${runnerRows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>`,
|
||||
);
|
||||
}
|
||||
@ -634,6 +670,32 @@ function leaseRow(
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function runnerRow(
|
||||
runner: ExternalRunnerRecord,
|
||||
context: { admin: boolean; owner: string; org: string },
|
||||
): string {
|
||||
const ownership =
|
||||
context.admin && (runner.owner !== context.owner || runner.org !== context.org)
|
||||
? "system"
|
||||
: "mine";
|
||||
const state = runner.stale ? "stale" : "active";
|
||||
const subline =
|
||||
context.admin && ownership === "system"
|
||||
? `${runner.owner || "unknown"} · ${runner.org || "unknown"}`
|
||||
: runner.id;
|
||||
const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-";
|
||||
return `<tr data-filter-tags="${escapeHTML([state, ownership, runner.provider, runner.status, runner.repo, runner.workflow, runner.job, runner.ref].filter(Boolean).join(" "))}">
|
||||
<td><span class="lease-link"><strong>${escapeHTML(runner.id)}</strong><small>${escapeHTML(subline)}</small></span></td>
|
||||
<td><span class="pill" data-tone="${runner.stale ? "warn" : runnerStatusTone(runner.status)}">${escapeHTML(runner.status || "-")}</span></td>
|
||||
<td>${providerBadge(runner.provider)}</td>
|
||||
<td>${escapeHTML(runner.repo || "-")}</td>
|
||||
<td>${escapeHTML(runner.workflow || "-")}</td>
|
||||
<td>${escapeHTML(jobRef)}</td>
|
||||
${timeCell(runnerSortTime(runner))}
|
||||
<td></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function portalHeader(options: PortalHeaderOptions): string {
|
||||
const variant = options.variant || "top";
|
||||
const headerClass = variant === "bar" ? "vnc-bar" : "top";
|
||||
@ -654,6 +716,10 @@ function leaseOwnership(lease: LeaseRecord, owner: string, org: string): "mine"
|
||||
return lease.owner === owner && lease.org === org ? "mine" : "system";
|
||||
}
|
||||
|
||||
function runnerSortTime(runner: ExternalRunnerRecord): string {
|
||||
return runner.lastSeenAt || runner.updatedAt || runner.createdAt || runner.firstSeenAt;
|
||||
}
|
||||
|
||||
function runRow(run: RunRecord): string {
|
||||
const stateTone = run.state === "succeeded" ? "ok" : run.state === "failed" ? "bad" : "warn";
|
||||
return `<tr data-filter-tags="${escapeHTML([run.state, run.provider, run.target || "linux"].filter(Boolean).join(" "))}">
|
||||
@ -1018,6 +1084,9 @@ function telemetryStorage(
|
||||
}
|
||||
|
||||
function providerIcon(provider: string): string {
|
||||
if (provider === "blacksmith-testbox") {
|
||||
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v5H4z"/><path d="M4 13h16v5H4z"/><path d="M8 8.5h.01M8 15.5h.01M12 8.5h5M12 15.5h5"/></svg>`;
|
||||
}
|
||||
if (provider === "aws") {
|
||||
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 15.5c3.8 2.2 9.1 2.5 14.8.9"/><path d="M17.5 13.2 20 16l-3.7.7"/><path d="M7 8.5h10l1.8 4H5.2z"/></svg>`;
|
||||
}
|
||||
@ -1027,6 +1096,19 @@ function providerIcon(provider: string): string {
|
||||
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v12H4z"/><path d="m7 10 3 2-3 2M12 15h5"/></svg>`;
|
||||
}
|
||||
|
||||
function runnerStatusTone(status: string): string {
|
||||
if (status === "ready" || status === "running") {
|
||||
return "ok";
|
||||
}
|
||||
if (status === "queued" || status === "starting" || status === "pending") {
|
||||
return "warn";
|
||||
}
|
||||
if (status === "failed" || status === "error") {
|
||||
return "bad";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function targetIcon(target: string): string {
|
||||
if (target === "windows") {
|
||||
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5 11 4v7H4z"/><path d="m13 3.7 7-1.5V11h-7z"/><path d="M4 13h7v7l-7-1.5z"/><path d="M13 13h7v8.8l-7-1.5z"/></svg>`;
|
||||
@ -1095,7 +1177,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
html { min-height:100%; background:var(--bg); }
|
||||
body { margin:0; min-height:100vh; overflow-x:hidden; background:var(--bg); color:var(--fg); font:14px/1.45 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
||||
main { width:min(1180px, calc(100vw - 32px)); max-width:100%; margin:0 auto; padding:10px 0 22px; }
|
||||
.portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1fr); gap:8px; padding:6px 0 8px; overflow:hidden; }
|
||||
.portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1.1fr) minmax(220px,0.9fr); gap:8px; padding:6px 0 8px; overflow:hidden; }
|
||||
.lease-shell { grid-template-rows:auto auto minmax(0,1fr); }
|
||||
.run-shell { height:auto; min-height:100dvh; overflow:visible; grid-template-rows:auto; }
|
||||
h1,h2,p { margin:0; }
|
||||
@ -1188,6 +1270,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.icon-label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.icon-label[data-provider="aws"] svg { color:#fbbf24; }
|
||||
.icon-label[data-provider="hetzner"] svg { color:#ef4444; }
|
||||
.icon-label[data-provider="blacksmith-testbox"] svg { color:#a78bfa; }
|
||||
.icon-label[data-target="linux"] svg { color:#34d399; }
|
||||
.icon-label[data-target="windows"] svg { color:#38bdf8; }
|
||||
.icon-label[data-target="macos"] svg { color:#d8b4fe; }
|
||||
@ -1222,6 +1305,14 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.lease-table th:nth-child(6) { width:118px; }
|
||||
.lease-table th:nth-child(7) { width:148px; }
|
||||
.lease-table th:nth-child(8) { width:24px; }
|
||||
.runner-table th:nth-child(1) { width:22%; }
|
||||
.runner-table th:nth-child(2) { width:88px; }
|
||||
.runner-table th:nth-child(3) { width:148px; }
|
||||
.runner-table th:nth-child(4) { width:118px; }
|
||||
.runner-table th:nth-child(5) { width:28%; }
|
||||
.runner-table th:nth-child(6) { width:120px; }
|
||||
.runner-table th:nth-child(7) { width:138px; }
|
||||
.runner-table th:nth-child(8) { width:24px; }
|
||||
.run-table th:nth-child(2) { width:104px; }
|
||||
.run-table th:nth-child(3) { width:112px; }
|
||||
.run-table th:nth-child(4) { width:92px; }
|
||||
|
||||
@ -239,6 +239,39 @@ export interface RunTelemetryRequest {
|
||||
telemetry?: Partial<LeaseTelemetry>;
|
||||
}
|
||||
|
||||
export interface ExternalRunnerInput {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
status?: string;
|
||||
repo?: string;
|
||||
workflow?: string;
|
||||
job?: string;
|
||||
ref?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ExternalRunnerSyncRequest {
|
||||
provider?: string;
|
||||
runners?: ExternalRunnerInput[];
|
||||
}
|
||||
|
||||
export interface ExternalRunnerRecord {
|
||||
id: string;
|
||||
provider: string;
|
||||
owner: string;
|
||||
org: string;
|
||||
status: string;
|
||||
repo?: string;
|
||||
workflow?: string;
|
||||
job?: string;
|
||||
ref?: string;
|
||||
createdAt?: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
updatedAt: string;
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
export interface RunEventRecord {
|
||||
runID: string;
|
||||
seq: number;
|
||||
|
||||
@ -9,7 +9,13 @@ import {
|
||||
resetWebVNCBridge,
|
||||
type WebVNCBuffer,
|
||||
} from "../src/fleet";
|
||||
import type { Env, LeaseRecord, ProvisioningAttempt, RunRecord } from "../src/types";
|
||||
import type {
|
||||
Env,
|
||||
ExternalRunnerRecord,
|
||||
LeaseRecord,
|
||||
ProvisioningAttempt,
|
||||
RunRecord,
|
||||
} from "../src/types";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@ -646,6 +652,49 @@ describe("fleet lease identity and idle", () => {
|
||||
windowsMode: "wsl2",
|
||||
}),
|
||||
);
|
||||
await fleet.fetch(
|
||||
request("POST", "/v1/runners/sync", {
|
||||
headers: {
|
||||
"x-crabbox-owner": "peter@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
},
|
||||
body: {
|
||||
provider: "blacksmith-testbox",
|
||||
runners: [
|
||||
{
|
||||
id: "tbx_01testbox",
|
||||
status: "ready",
|
||||
repo: "openclaw",
|
||||
workflow: ".github/workflows/ci-check-testbox.yml",
|
||||
job: "check",
|
||||
ref: "main",
|
||||
createdAt: "2026-05-05T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fleet.fetch(
|
||||
request("POST", "/v1/runners/sync", {
|
||||
headers: {
|
||||
"x-crabbox-owner": "friend@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
},
|
||||
body: {
|
||||
provider: "blacksmith-testbox",
|
||||
runners: [
|
||||
{
|
||||
id: "tbx_friendbox",
|
||||
status: "ready",
|
||||
repo: "openclaw",
|
||||
workflow: ".github/workflows/ci-check-testbox.yml",
|
||||
job: "check",
|
||||
ref: "main",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await fleet.fetch(
|
||||
request("GET", "/portal", {
|
||||
@ -666,6 +715,11 @@ describe("fleet lease identity and idle", () => {
|
||||
'data-filter-buttons="active:active,ended:ended,aws:aws,hetzner:hetzner,linux:linux,macos:macos,windows:windows,all:all"',
|
||||
);
|
||||
expect(body).toContain('data-filter-default="active"');
|
||||
expect(body).toContain("external runners");
|
||||
expect(body).toContain("tbx_01testbox");
|
||||
expect(body).toContain("blacksmith-testbox");
|
||||
expect(body).toContain("ci-check-testbox.yml");
|
||||
expect(body).not.toContain("tbx_friendbox");
|
||||
expect(body).toContain('data-provider="hetzner"');
|
||||
expect(body).toContain('data-target="linux"');
|
||||
expect(body).toContain('data-target="windows"');
|
||||
@ -688,6 +742,75 @@ describe("fleet lease identity and idle", () => {
|
||||
expect(body).not.toContain("amber-krill");
|
||||
});
|
||||
|
||||
it("syncs external runner visibility and marks missing runners stale", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage);
|
||||
const headers = {
|
||||
"x-crabbox-owner": "peter@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
};
|
||||
|
||||
const sync = await fleet.fetch(
|
||||
request("POST", "/v1/runners/sync", {
|
||||
headers,
|
||||
body: {
|
||||
provider: "blacksmith-testbox",
|
||||
runners: [
|
||||
{
|
||||
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
||||
status: "ready",
|
||||
repo: "openclaw",
|
||||
workflow: ".github/workflows/ci-check-testbox.yml",
|
||||
job: "check",
|
||||
ref: "main",
|
||||
createdAt: "2026-05-06T09:45:16.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sync.status).toBe(200);
|
||||
const synced = (await sync.json()) as { runners: ExternalRunnerRecord[] };
|
||||
expect(synced.runners).toHaveLength(1);
|
||||
expect(synced.runners[0]).toMatchObject({
|
||||
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
||||
provider: "blacksmith-testbox",
|
||||
status: "ready",
|
||||
repo: "openclaw",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
});
|
||||
|
||||
const friendList = await fleet.fetch(
|
||||
request("GET", "/v1/runners", {
|
||||
headers: {
|
||||
"x-crabbox-owner": "friend@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const friendBody = (await friendList.json()) as { runners: ExternalRunnerRecord[] };
|
||||
expect(friendBody.runners).toHaveLength(0);
|
||||
|
||||
const staleSync = await fleet.fetch(
|
||||
request("POST", "/v1/runners/sync", {
|
||||
headers,
|
||||
body: {
|
||||
provider: "blacksmith-testbox",
|
||||
runners: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(staleSync.status).toBe(200);
|
||||
const staleBody = (await staleSync.json()) as { stale: ExternalRunnerRecord[] };
|
||||
expect(staleBody.stale).toHaveLength(1);
|
||||
expect(staleBody.stale[0]).toMatchObject({
|
||||
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
||||
status: "missing",
|
||||
stale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows non-owned runner leases only in the admin portal", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user