Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ textarea { resize: vertical; line-height: 1.5; }
.brand:hover { text-decoration: none; }
.brand img { border-radius: 5px; }
.side-root { color: var(--ink-3); font-size: 12.5px; padding: 2px 6px 0 34px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.setup-callout { margin: 12px 6px 0; padding: 11px 10px; border: 1px solid var(--needs); border-radius: 8px; background: var(--needs-soft); font-size: 12.5px; }
.setup-callout strong { display: block; font-size: 13px; color: var(--ink); }
.setup-callout p { margin: 4px 0 7px; color: var(--ink-2); line-height: 1.35; }
.setup-callout a { color: var(--ink); font-weight: 500; }
.runs { flex: 1; overflow-y: auto; margin-top: 18px; }
.group + .group { margin-top: 18px; }
.group h2 { font-size: 12.5px; font-weight: 500; color: var(--ink-3); padding: 0 8px 4px; display: flex; justify-content: space-between; }
Expand Down Expand Up @@ -383,6 +387,8 @@ kbd { font-family: var(--sans); font-size: 12px; font-weight: 500; border: 1px s
.welcome { max-width: 520px; margin: 14vh auto 0; text-align: center; }
.welcome h1 { font-size: 24px; margin-bottom: 8px; }
.welcome p { color: var(--ink-2); margin-bottom: 20px; }
.setup-welcome code { display: inline-block; padding: 9px 12px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--canvas); color: var(--ink); }
.setup-welcome .buttons { justify-content: center; }

@media (max-width: 1180px) {
.work { grid-template-columns: minmax(0, 1fr); }
Expand Down
30 changes: 30 additions & 0 deletions internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
const bytes = (n) => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} bytes`);
const sha = (s) => (s ? s.slice(0, 7) : '');
const model = (m) => m || 'harness default';
const SETUP_GUIDE = 'https://sam-bretz.github.io/envctl/first-workflow/';
const INIT_COMMAND = 'envctl init --project <prefix>';
// stageModel prefers the model a role actually ran over the one configured
// for it: "harness default" never answered which model that turned out to
// be, and an alias never showed the version behind it.
Expand Down Expand Up @@ -476,6 +478,8 @@
function renderSide() {
const st = ui.state;
$('root').textContent = st ? st.root.replace(/^\/Users\/[^/]+/, '~') : '';
const needsSetup = st && st.manifest && (!st.manifest.present || !st.manifest.valid);
$('new-run').disabled = !!needsSetup;
const link = $('link');
link.className = 'link';
if (ui.link === 'ok' && st && !st.error) link.textContent = 'Live';
Expand All @@ -488,6 +492,13 @@
if (!items.length) continue;
groups.push(h('section', { class: 'group' }, h('h2', {}, title, h('span', { text: String(items.length) })), items.map(runRow)));
}
if (needsSetup) {
const invalid = st.manifest.present && !st.manifest.valid;
groups.unshift(h('div', { class: 'setup-callout' },
h('strong', { text: invalid ? 'Fix envctl.yaml to start runs' : 'Add envctl.yaml to start runs' }),
h('p', { text: invalid ? 'The workflow configuration needs attention.' : 'This repository has no workflow configuration yet.' }),
h('a', { href: SETUP_GUIDE, target: '_blank', rel: 'noopener noreferrer', text: 'Read the setup guide' })));
}
if (!groups.length) groups.push(h('p', { class: 'empty-list', text: st ? 'No runs yet.' : 'Loading…' }));
$('runs').replaceChildren(...groups);
}
Expand Down Expand Up @@ -533,12 +544,31 @@
const st = ui.state;
if (st && st.error) return h('div', { class: 'welcome' }, h('h1', { text: 'The coordinator is offline' }), h('p', { text: 'Start it with envctl daemon serve, or run any envctl run command. This page reconnects on its own.' }));
if (!st) return h('div', { class: 'welcome' }, h('p', { text: 'Connecting to envctl…' }));
if (st.manifest && !st.manifest.present) return setupWelcome('Add envctl.yaml to start runs', 'envctl needs a workflow configuration at the root of this repository.');
if (st.manifest && !st.manifest.valid) return setupWelcome('Fix envctl.yaml to start runs', st.manifest.error || 'The workflow configuration could not be loaded.');
return h('div', { class: 'welcome' },
h('h1', { text: 'Start your first run' }),
h('p', { text: 'Describe a change. Agents plan, build and check it in their own VM, and ask you before anything is published.' }),
h('button', { type: 'button', class: 'btn btn-primary', text: 'New run', onclick: newRunDialog }));
}

function setupWelcome(title, detail) {
return h('div', { class: 'welcome setup-welcome' },
h('h1', { text: title }),
h('p', { text: detail }),
h('p', {}, h('code', { text: INIT_COMMAND })),
h('div', { class: 'buttons' },
h('a', { class: 'btn btn-primary', href: SETUP_GUIDE, target: '_blank', rel: 'noopener noreferrer', text: 'Open setup guide' }),
h('button', { type: 'button', class: 'btn btn-quiet', text: 'Copy init command', onclick: async () => {
try {
await navigator.clipboard.writeText(INIT_COMMAND);
say('Copied the init command. Run it from this repository.');
} catch {
say(`Run this from the repository: ${INIT_COMMAND}`);
}
} })));
}

function renderHistoryNote(run, rev) {
const note = $('history-note');
if (!viewingHistory(run)) return note.replaceChildren();
Expand Down
13 changes: 12 additions & 1 deletion internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ func (s *Server) artifactPage(w http.ResponseWriter, r *http.Request) {
func (s *Server) snapshot(ctx context.Context) State {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
st := State{Root: s.Root, Runs: []RunView{}, At: s.now()}
st := State{Root: s.Root, Manifest: s.manifest(), Runs: []RunView{}, At: s.now()}
runs, err := s.API.List(ctx)
if err != nil {
st.Error = "The coordinator is not answering: " + err.Error()
Expand All @@ -331,6 +331,17 @@ func (s *Server) snapshot(ctx context.Context) State {
return st
}

func (s *Server) manifest() ManifestView {
_, err := workflow.Load(s.Root)
if err == nil {
return ManifestView{Present: true, Valid: true}
}
if errors.Is(err, os.ErrNotExist) {
return ManifestView{}
}
return ManifestView{Present: true, Error: err.Error()}
}

func (s *Server) state(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.snapshot(r.Context()))
}
Expand Down
11 changes: 11 additions & 0 deletions internal/web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ func TestOnlyThisMachineWithTheTokenCanUseTheDashboard(t *testing.T) {
}
}

func TestStateReportsMissingManifestForDashboardSetup(t *testing.T) {
s, _, _ := fixture(t)
if err := os.Remove(filepath.Join(s.Root, "envctl.yaml")); err != nil {
t.Fatal(err)
}
st := s.snapshot(context.Background())
if st.Manifest.Present || st.Manifest.Valid {
t.Fatalf("missing manifest was not reported: %+v", st.Manifest)
}
}

func TestCreateSelectsTheWorkflowAndActionsKeepTheShownVersion(t *testing.T) {
_, api, h := fixture(t)
headers := map[string]string{"Content-Type": "application/json", "X-Envctl-Token": testToken}
Expand Down
17 changes: 13 additions & 4 deletions internal/web/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ import (
// every state the dashboard derives already computed.

type State struct {
Root string `json:"root"`
Runs []RunView `json:"runs"`
Error string `json:"error,omitempty"`
At time.Time `json:"at"`
Root string `json:"root"`
Manifest ManifestView `json:"manifest"`
Runs []RunView `json:"runs"`
Error string `json:"error,omitempty"`
At time.Time `json:"at"`
}

// ManifestView gives the dashboard enough context to make an unconfigured
// repository recoverable without exposing the workflow configuration itself.
type ManifestView struct {
Present bool `json:"present"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}

type RunView struct {
Expand Down
Loading