feat(portal): link testbox runners to actions
This commit is contained in:
parent
2a4e08af24
commit
eefd71fbb1
@ -21,6 +21,7 @@
|
||||
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
|
||||
- Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`.
|
||||
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
|
||||
- Added best-effort GitHub Actions run and workflow links for external Blacksmith Testbox rows in the portal.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@ -159,7 +159,8 @@ blacksmith:
|
||||
|
||||
`crabbox list --provider blacksmith-testbox` also refreshes muted external
|
||||
runner rows in the portal lease table from the current all-status Testbox list
|
||||
when coordinator auth is configured. Those rows are
|
||||
when coordinator auth is configured. When GitHub is reachable, Crabbox also
|
||||
links those rows back to the inferred Actions run and workflow. Those rows are
|
||||
visibility-only records for Blacksmith-owned Testboxes, not Crabbox leases.
|
||||
|
||||
Optional Daytona sandbox:
|
||||
|
||||
@ -22,8 +22,10 @@ shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref,
|
||||
and created time when the upstream table exposes those columns.
|
||||
When coordinator auth is configured, the same list command also refreshes
|
||||
owner-scoped external runner rows in the portal lease table from the current
|
||||
all-status Blacksmith list. Missing runners from later syncs are marked stale
|
||||
rather than treated as Crabbox leases.
|
||||
all-status Blacksmith list. Crabbox also attempts to infer the matching GitHub
|
||||
Actions run/workflow from the row's repo, workflow, ref, and created time.
|
||||
Missing runners from later syncs are marked stale rather than treated as Crabbox
|
||||
leases.
|
||||
|
||||
In `daytona` and `islo` modes, rendering is core-owned: human output and `--json`
|
||||
use the normalized Crabbox lease view.
|
||||
|
||||
@ -98,9 +98,11 @@ native JSON output, Crabbox should switch to that and drop table parsing.
|
||||
When coordinator auth is configured, `crabbox list --provider blacksmith-testbox`
|
||||
also performs a best-effort sync of the current all-status Blacksmith list into
|
||||
the portal lease table. Those muted rows are owner-scoped visibility records for
|
||||
Blacksmith-owned Testboxes. They are not Crabbox leases, do not expose access
|
||||
actions, do not heartbeat, do not participate in Crabbox expiry or cost control,
|
||||
and become stale when a later sync does not see the runner.
|
||||
Blacksmith-owned Testboxes. When the row includes enough context, Crabbox queries
|
||||
GitHub Actions and links the row to the closest workflow run plus the workflow
|
||||
definition. They are not Crabbox leases, do not expose box access actions, do
|
||||
not heartbeat, do not participate in Crabbox expiry or cost control, and become
|
||||
stale when a later sync does not see the runner.
|
||||
|
||||
## Auth
|
||||
|
||||
|
||||
@ -66,8 +66,8 @@ normal users. It defaults to active leases when any are active, and falls back t
|
||||
all visible leases when the active list is empty. External runner rows, currently
|
||||
Blacksmith Testboxes synced from the CLI's current all-status list, render in the
|
||||
same grid as muted, disabled rows with search, pagination, status/provider
|
||||
filters, and stale markers when the next sync no longer sees a previously
|
||||
visible runner.
|
||||
filters, inferred GitHub Actions run/workflow links when available, and stale
|
||||
markers when the next sync no longer sees a previously visible runner.
|
||||
|
||||
`/portal/leases/{id-or-slug}` is the authenticated lease detail page. It shows
|
||||
the lease state, bridge status, compact provider/target badges, latest Linux
|
||||
|
||||
@ -50,8 +50,9 @@ Direct-provider mode does not have a central heartbeat or alarm. It labels machi
|
||||
Delegated external runners, such as Blacksmith Testboxes, are visibility-only
|
||||
records in the coordinator. `crabbox list --provider blacksmith-testbox` syncs
|
||||
the current all-status Blacksmith table into muted `/portal` lease-grid rows,
|
||||
and a later sync marks missing runners stale. They do not heartbeat and do not
|
||||
participate in Crabbox lease expiry, cleanup, or cost accounting.
|
||||
adds inferred GitHub Actions run/workflow links when available, and a later sync
|
||||
marks missing runners stale. They do not heartbeat and do not participate in
|
||||
Crabbox lease expiry, cleanup, or cost accounting.
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
@ -161,15 +161,22 @@ type CoordinatorRunEventsResponse struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
ActionsRepo string `json:"actionsRepo,omitempty"`
|
||||
ActionsRunID string `json:"actionsRunID,omitempty"`
|
||||
ActionsRunURL string `json:"actionsRunURL,omitempty"`
|
||||
ActionsRunStatus string `json:"actionsRunStatus,omitempty"`
|
||||
ActionsRunConclusion string `json:"actionsRunConclusion,omitempty"`
|
||||
ActionsWorkflowName string `json:"actionsWorkflowName,omitempty"`
|
||||
ActionsWorkflowURL string `json:"actionsWorkflowURL,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorExternalRunnerSyncResponse struct {
|
||||
|
||||
@ -4,10 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
||||
|
||||
func (a App) list(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("list", a.Stderr)
|
||||
@ -82,6 +88,7 @@ func (a App) syncExternalRunnersBestEffort(ctx context.Context, cfg Config, back
|
||||
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
|
||||
return
|
||||
}
|
||||
enrichExternalRunnerActionsBestEffort(ctx, cfg, runners)
|
||||
if _, err := client.SyncExternalRunners(ctx, "blacksmith-testbox", runners); err != nil {
|
||||
fmt.Fprintf(a.Stderr, "warning: external runner portal sync failed: %v\n", err)
|
||||
}
|
||||
@ -105,6 +112,168 @@ func coordinatorExternalRunnersFromListView(view any) ([]CoordinatorExternalRunn
|
||||
return runners, nil
|
||||
}
|
||||
|
||||
type externalRunnerActionsRun struct {
|
||||
DatabaseID int64 `json:"databaseId"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
HeadBranch string `json:"headBranch"`
|
||||
URL string `json:"url"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
DisplayTitle string `json:"displayTitle"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func enrichExternalRunnerActionsBestEffort(ctx context.Context, cfg Config, runners []CoordinatorExternalRunner) {
|
||||
cache := map[string][]externalRunnerActionsRun{}
|
||||
for i := range runners {
|
||||
repo, ok := externalRunnerGitHubRepo(cfg, runners[i])
|
||||
if !ok || runners[i].Workflow == "" {
|
||||
continue
|
||||
}
|
||||
key := repo.Slug() + "\x00" + runners[i].Workflow + "\x00" + runners[i].Ref
|
||||
runs, seen := cache[key]
|
||||
if !seen {
|
||||
var err error
|
||||
runs, err = externalRunnerGitHubRuns(ctx, repo, runners[i].Workflow, runners[i].Ref)
|
||||
if err != nil {
|
||||
cache[key] = nil
|
||||
continue
|
||||
}
|
||||
cache[key] = runs
|
||||
}
|
||||
run, ok := matchExternalRunnerActionRun(runners[i], runs)
|
||||
if !ok {
|
||||
runners[i].ActionsRepo = repo.Slug()
|
||||
runners[i].ActionsWorkflowURL = externalRunnerWorkflowURL(repo, runners[i].Workflow)
|
||||
continue
|
||||
}
|
||||
runners[i].ActionsRepo = repo.Slug()
|
||||
runners[i].ActionsRunID = strconv.FormatInt(run.DatabaseID, 10)
|
||||
runners[i].ActionsRunURL = run.URL
|
||||
runners[i].ActionsRunStatus = run.Status
|
||||
runners[i].ActionsRunConclusion = run.Conclusion
|
||||
runners[i].ActionsWorkflowName = run.WorkflowName
|
||||
runners[i].ActionsWorkflowURL = externalRunnerWorkflowURL(repo, runners[i].Workflow)
|
||||
}
|
||||
}
|
||||
|
||||
func externalRunnerGitHubRepo(cfg Config, runner CoordinatorExternalRunner) (GitHubRepo, bool) {
|
||||
if strings.Contains(runner.Repo, "/") {
|
||||
repo, err := parseGitHubRepo(runner.Repo)
|
||||
return repo, err == nil
|
||||
}
|
||||
owner := strings.TrimSpace(cfg.Blacksmith.Org)
|
||||
if owner == "" && cfg.Actions.Repo != "" {
|
||||
if repo, err := parseGitHubRepo(cfg.Actions.Repo); err == nil {
|
||||
owner = repo.Owner
|
||||
}
|
||||
}
|
||||
if owner == "" || runner.Repo == "" {
|
||||
return GitHubRepo{}, false
|
||||
}
|
||||
repo, err := parseGitHubRepo(owner + "/" + runner.Repo)
|
||||
return repo, err == nil
|
||||
}
|
||||
|
||||
func externalRunnerGitHubRuns(ctx context.Context, repo GitHubRepo, workflow, ref string) ([]externalRunnerActionsRun, error) {
|
||||
args := []string{
|
||||
"run", "list",
|
||||
"--repo", repo.Slug(),
|
||||
"--workflow", workflow,
|
||||
"--limit", "30",
|
||||
"--json", "databaseId,status,conclusion,createdAt,updatedAt,headBranch,url,workflowName,displayTitle,name",
|
||||
}
|
||||
if ref != "" {
|
||||
args = append(args, "--branch", ref)
|
||||
}
|
||||
out, err := ghOutput(ctx, "", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var runs []externalRunnerActionsRun
|
||||
if err := json.Unmarshal([]byte(stripANSI(out)), &runs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func matchExternalRunnerActionRun(runner CoordinatorExternalRunner, runs []externalRunnerActionsRun) (externalRunnerActionsRun, bool) {
|
||||
if len(runs) == 0 {
|
||||
return externalRunnerActionsRun{}, false
|
||||
}
|
||||
runnerTime, hasRunnerTime := parseExternalRunnerTime(runner.CreatedAt)
|
||||
bestIndex := -1
|
||||
bestDelta := int64(0)
|
||||
for i, run := range runs {
|
||||
if runner.Ref != "" && run.HeadBranch != "" && run.HeadBranch != runner.Ref {
|
||||
continue
|
||||
}
|
||||
if !hasRunnerTime {
|
||||
return run, true
|
||||
}
|
||||
runTime, ok := parseExternalRunnerTime(run.CreatedAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := runTime.Sub(runnerTime)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > 6*time.Hour {
|
||||
continue
|
||||
}
|
||||
deltaMillis := delta.Milliseconds()
|
||||
if bestIndex < 0 || deltaMillis < bestDelta {
|
||||
bestIndex = i
|
||||
bestDelta = deltaMillis
|
||||
}
|
||||
}
|
||||
if bestIndex < 0 {
|
||||
return externalRunnerActionsRun{}, false
|
||||
}
|
||||
return runs[bestIndex], true
|
||||
}
|
||||
|
||||
func parseExternalRunnerTime(value string) (time.Time, bool) {
|
||||
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func externalRunnerWorkflowURL(repo GitHubRepo, workflow string) string {
|
||||
if repo.Slug() == "" || workflow == "" {
|
||||
return ""
|
||||
}
|
||||
workflow = strings.TrimPrefix(strings.TrimSpace(workflow), "/")
|
||||
if strings.HasPrefix(workflow, ".github/workflows/") {
|
||||
workflow = path.Base(workflow)
|
||||
}
|
||||
if !strings.HasSuffix(workflow, ".yml") && !strings.HasSuffix(workflow, ".yaml") && !allDigits(workflow) {
|
||||
return ""
|
||||
}
|
||||
return "https://github.com/" + repo.Slug() + "/actions/workflows/" + url.PathEscape(workflow)
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripANSI(value string) string {
|
||||
return ansiEscapePattern.ReplaceAllString(value, "")
|
||||
}
|
||||
|
||||
func activeCoordinatorLeaseIDs(leases []CoordinatorLease) map[string]struct{} {
|
||||
ids := make(map[string]struct{}, len(leases))
|
||||
for _, lease := range leases {
|
||||
|
||||
@ -139,6 +139,54 @@ func TestCoordinatorExternalRunnersFromBlacksmithListView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalRunnerGitHubRepoFallsBackToBlacksmithOrg(t *testing.T) {
|
||||
cfg := Config{}
|
||||
cfg.Blacksmith.Org = "openclaw"
|
||||
repo, ok := externalRunnerGitHubRepo(cfg, CoordinatorExternalRunner{Repo: "crabbox"})
|
||||
if !ok {
|
||||
t.Fatal("repo not inferred")
|
||||
}
|
||||
if repo.Slug() != "openclaw/crabbox" {
|
||||
t.Fatalf("repo=%q", repo.Slug())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchExternalRunnerActionRunChoosesClosestCreatedAt(t *testing.T) {
|
||||
runner := CoordinatorExternalRunner{
|
||||
Ref: "main",
|
||||
CreatedAt: "2026-05-06T10:00:00Z",
|
||||
}
|
||||
run, ok := matchExternalRunnerActionRun(runner, []externalRunnerActionsRun{
|
||||
{DatabaseID: 1, HeadBranch: "main", CreatedAt: "2026-05-06T08:00:00Z"},
|
||||
{DatabaseID: 2, HeadBranch: "main", CreatedAt: "2026-05-06T10:02:00Z"},
|
||||
{DatabaseID: 3, HeadBranch: "feature", CreatedAt: "2026-05-06T10:01:00Z"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("run not matched")
|
||||
}
|
||||
if run.DatabaseID != 2 {
|
||||
t.Fatalf("run=%d, want 2", run.DatabaseID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalRunnerWorkflowURLUsesWorkflowBasename(t *testing.T) {
|
||||
got := externalRunnerWorkflowURL(
|
||||
GitHubRepo{Owner: "openclaw", Name: "openclaw"},
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
)
|
||||
want := "https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml"
|
||||
if got != want {
|
||||
t.Fatalf("url=%q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSIRemovesGitHubColorOutput(t *testing.T) {
|
||||
got := stripANSI("\x1b[1;37m[\x1b[m{\"databaseId\":1}]")
|
||||
if got != "[{\"databaseId\":1}]" {
|
||||
t.Fatalf("stripped=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatInterval(t *testing.T) {
|
||||
tests := map[time.Duration]time.Duration{
|
||||
0: time.Minute,
|
||||
|
||||
@ -2251,18 +2251,50 @@ function sanitizeExternalRunner(
|
||||
provider,
|
||||
status: nonSecretString(input.status).toLowerCase() || "unknown",
|
||||
};
|
||||
for (const key of ["repo", "workflow", "job", "ref"] as const) {
|
||||
for (const key of [
|
||||
"repo",
|
||||
"workflow",
|
||||
"job",
|
||||
"ref",
|
||||
"actionsRepo",
|
||||
"actionsRunID",
|
||||
"actionsRunStatus",
|
||||
"actionsRunConclusion",
|
||||
"actionsWorkflowName",
|
||||
] as const) {
|
||||
const value = nonSecretString(input[key]);
|
||||
if (value) {
|
||||
runner[key] = value;
|
||||
}
|
||||
}
|
||||
for (const key of ["actionsRunURL", "actionsWorkflowURL"] as const) {
|
||||
const value = sanitizeGithubURL(input[key]);
|
||||
if (value) {
|
||||
runner[key] = value;
|
||||
}
|
||||
}
|
||||
if (createdAt) {
|
||||
runner.createdAt = createdAt;
|
||||
}
|
||||
return runner;
|
||||
}
|
||||
|
||||
function sanitizeGithubURL(value: unknown): string {
|
||||
const raw = nonSecretString(value);
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") {
|
||||
return "";
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRunnerTimestamp(value: string | undefined, now: Date): string | undefined {
|
||||
const parsed = Date.parse(value ?? "");
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
||||
@ -672,18 +672,35 @@ function externalRunnerLeaseRow(
|
||||
? `${runner.id} · ${runner.owner || "unknown"}`
|
||||
: [runner.repo, runner.workflow].filter(Boolean).join(" · ") || runner.id;
|
||||
const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-";
|
||||
const actionsLinks = externalRunnerActionsLinks(runner);
|
||||
return `<tr class="external-row" aria-disabled="true" data-filter-tags="${escapeHTML([state, "external", 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><span class="muted" title="Blacksmith owns runner host details">-</span></td>
|
||||
<td><span title="${escapeHTML([runner.repo, runner.workflow, jobRef].filter(Boolean).join(" · "))}">external</span></td>
|
||||
<td><span class="access-cell disabled-cell" title="external runner; no Crabbox access data">no access</span></td>
|
||||
<td><span title="${escapeHTML([runner.repo, runner.workflow, jobRef].filter(Boolean).join(" · "))}">${actionsLinks || "external"}</span></td>
|
||||
<td><span class="access-cell disabled-cell" title="external runner; no Crabbox access data">${actionsLinks ? "no box access" : "no access"}</span></td>
|
||||
${timeCell(runnerSortTime(runner))}
|
||||
<td></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function externalRunnerActionsLinks(runner: ExternalRunnerRecord): string {
|
||||
const links = [];
|
||||
if (runner.actionsRunURL) {
|
||||
const status = [runner.actionsRunStatus, runner.actionsRunConclusion].filter(Boolean).join("/");
|
||||
links.push(
|
||||
`<a class="row-link" href="${escapeHTML(runner.actionsRunURL)}" target="_blank" rel="noopener" title="${escapeHTML(status || "GitHub Actions run")}">run</a>`,
|
||||
);
|
||||
}
|
||||
if (runner.actionsWorkflowURL) {
|
||||
links.push(
|
||||
`<a class="row-link secondary" href="${escapeHTML(runner.actionsWorkflowURL)}" target="_blank" rel="noopener" title="${escapeHTML(runner.actionsWorkflowName || runner.workflow || "GitHub Actions workflow")}">workflow</a>`,
|
||||
);
|
||||
}
|
||||
return links.length ? `<span class="row-links">${links.join("")}</span>` : "";
|
||||
}
|
||||
|
||||
function portalHeader(options: PortalHeaderOptions): string {
|
||||
const variant = options.variant || "top";
|
||||
const headerClass = variant === "bar" ? "vnc-bar" : "top";
|
||||
@ -1301,6 +1318,10 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.external-row .lease-link strong::after { content:"external"; display:inline-flex; margin-left:8px; min-height:18px; align-items:center; padding:0 6px; border:1px solid var(--line); border-radius:999px; color:#8b949e; font-size:10px; font-weight:700; text-transform:uppercase; vertical-align:middle; }
|
||||
.external-row .icon-label svg { color:#7c8490; }
|
||||
.external-row .pill { opacity:0.82; }
|
||||
.row-links { display:inline-flex; align-items:center; gap:5px; min-width:0; }
|
||||
.row-link { display:inline-flex; align-items:center; min-height:22px; padding:0 7px; border:1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius:6px; color:#bae6fd; background:color-mix(in srgb, var(--accent) 9%, transparent); text-decoration:none; font-size:11px; font-weight:700; }
|
||||
.row-link.secondary { color:#cbd5e1; border-color:var(--line); background:#0c0e10; font-weight:600; }
|
||||
.row-link:hover { border-color:color-mix(in srgb, var(--accent) 58%, var(--line)); background:color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.lease-table th:nth-child(1) { width:25%; }
|
||||
.lease-table th:nth-child(2) { width:86px; }
|
||||
.lease-table th:nth-child(3),.lease-table th:nth-child(4) { width:104px; }
|
||||
|
||||
@ -248,6 +248,13 @@ export interface ExternalRunnerInput {
|
||||
job?: string;
|
||||
ref?: string;
|
||||
createdAt?: string;
|
||||
actionsRepo?: string;
|
||||
actionsRunID?: string;
|
||||
actionsRunURL?: string;
|
||||
actionsRunStatus?: string;
|
||||
actionsRunConclusion?: string;
|
||||
actionsWorkflowName?: string;
|
||||
actionsWorkflowURL?: string;
|
||||
}
|
||||
|
||||
export interface ExternalRunnerSyncRequest {
|
||||
@ -266,6 +273,13 @@ export interface ExternalRunnerRecord {
|
||||
job?: string;
|
||||
ref?: string;
|
||||
createdAt?: string;
|
||||
actionsRepo?: string;
|
||||
actionsRunID?: string;
|
||||
actionsRunURL?: string;
|
||||
actionsRunStatus?: string;
|
||||
actionsRunConclusion?: string;
|
||||
actionsWorkflowName?: string;
|
||||
actionsWorkflowURL?: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@ -669,6 +669,13 @@ describe("fleet lease identity and idle", () => {
|
||||
job: "check",
|
||||
ref: "main",
|
||||
createdAt: "2026-05-05T10:00:00.000Z",
|
||||
actionsRepo: "openclaw/openclaw",
|
||||
actionsRunID: "123456",
|
||||
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
||||
actionsRunStatus: "in_progress",
|
||||
actionsWorkflowName: "ci-check-testbox",
|
||||
actionsWorkflowURL:
|
||||
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -719,10 +726,15 @@ describe("fleet lease identity and idle", () => {
|
||||
expect(body).toContain("1 external");
|
||||
expect(body).toContain('class="external-row"');
|
||||
expect(body).toContain('aria-disabled="true"');
|
||||
expect(body).toContain("no access");
|
||||
expect(body).toContain("no box access");
|
||||
expect(body).toContain("tbx_01testbox");
|
||||
expect(body).toContain("blacksmith-testbox");
|
||||
expect(body).toContain("ci-check-testbox.yml");
|
||||
expect(body).toContain("https://github.com/openclaw/openclaw/actions/runs/123456");
|
||||
expect(body).toContain(
|
||||
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
||||
);
|
||||
expect(body).toContain('class="row-link"');
|
||||
expect(body).not.toContain("tbx_friendbox");
|
||||
expect(body).toContain('data-provider="hetzner"');
|
||||
expect(body).toContain('data-target="linux"');
|
||||
@ -768,6 +780,9 @@ describe("fleet lease identity and idle", () => {
|
||||
job: "check",
|
||||
ref: "main",
|
||||
createdAt: "2026-05-06T09:45:16.000000Z",
|
||||
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
||||
actionsWorkflowURL:
|
||||
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -783,6 +798,7 @@ describe("fleet lease identity and idle", () => {
|
||||
repo: "openclaw",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
||||
});
|
||||
|
||||
const friendList = await fleet.fetch(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user