Redesign the one-shot import form; fix its RPC timeout
UI: source URL now comes first, the repo name field is a locked "username/" prefix plus an editable suffix, and the suffix auto-fills from the URL's last path segment (still editable, never overwrites a name someone typed themselves). Fix found while testing the above: the RPC client used a flat 5s timeout for every call, including ImportRepo — far too short for an actual network clone, so real imports intermittently errored with an i/o timeout even when the clone was still running fine server-side. ImportRepo now gets its own deadline (admin.ImportCloneTimeout + 30s buffer) via a new callWithTimeout, instead of the shared 5s default.
4 files changed
+49 −11
M
cmd/gitfed-web/handlers_dashboard.go
+7 −4
M
cmd/gitfed-web/render.go
+18 −0
M
internal/admin/admin.go
+7 −4
M
internal/adminrpc/client.go
+17 −3
cmd/gitfed-web/handlers_dashboard.go
@@ -24,11 +24,14 @@ var dashboardTpl = newTpl("dashboard", `
<div class="gf-page-head-actions">
<details class="gf-new-repo">
<summary class="gf-btn" style="margin:0;">+ {{t .Lang "dashboard.import_repo"}}</summary>
- <form class="card" method="post" action="/repos/import" style="margin-top:0.75rem;">
- <label>{{t .Lang "dashboard.repo_name"}}</label>
- <input name="name" required placeholder="{{.Username}}/my-project">
+ <form class="card gf-import-form" method="post" action="/repos/import" style="margin-top:0.75rem;">
<label>{{t .Lang "dashboard.import_url_label"}}</label>
- <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git">
+ <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git" id="importUrl" autocomplete="off">
+ <label>{{t .Lang "dashboard.repo_name"}}</label>
+ <div class="gf-input-group">
+ <span class="prefix">{{.Username}}/</span>
+ <input name="name" required placeholder="my-project" id="importName" autocomplete="off">
+ </div>
<button type="submit">{{t .Lang "dashboard.import"}}</button>
</form>
</details>
cmd/gitfed-web/render.go
@@ -488,6 +488,11 @@ const shellHeadSrc = `<!doctype html>
.gf-new-repo summary { list-style: none; cursor: pointer; }
.gf-new-repo summary::-webkit-details-marker { display: none; }
.gf-new-repo[open] summary { margin-bottom: 0.5rem; }
+ .gf-new-repo form.card { min-width: 320px; }
+
+ .gf-input-group { display: flex; align-items: stretch; margin-top: 0.25rem; }
+ .gf-input-group input { margin-top: 0; border-radius: 0 6px 6px 0; }
+ .gf-input-group .prefix { display: flex; align-items: center; flex-shrink: 0; padding: 0 0.6rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-right: none; border-radius: 6px 0 0 6px; color: var(--text-faint); font-size: 0.9rem; font-family: var(--mono); white-space: nowrap; }
.gf-page-sub { color: var(--text-dim); font-size: 0.9rem; margin: -0.6rem 0 1.1rem; }
.gf-search { display: flex; align-items: center; gap: 0.6rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.55rem 0.8rem; margin-bottom: 1.1rem; color: var(--text-faint); }
@@ -747,6 +752,19 @@ const shellScriptJS = `
e.preventDefault();
}
});
+ // Derives a repo name from the pasted source URL's last path segment, on
+ // the one-shot import form — only while the name field is still empty or
+ // still holds a previous auto-fill, so it never clobbers a name someone
+ // typed themselves.
+ document.addEventListener('input', function (e) {
+ if (e.target.id !== 'importUrl') return;
+ var nameInput = document.getElementById('importName');
+ if (!nameInput || (nameInput.value !== '' && nameInput.value !== nameInput.dataset.autofilled)) return;
+ var m = e.target.value.trim().match(/\/([^\/]+?)(\.git)?\/?$/);
+ var derived = m ? m[1] : '';
+ nameInput.value = derived;
+ nameInput.dataset.autofilled = derived;
+ });
})();
`
internal/admin/admin.go
@@ -276,9 +276,12 @@ func (a *Admin) CreateRepo(name, ownerUsername string) error {
return a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path})
}
-// importCloneTimeout bounds how long a one-shot import's git clone may run,
-// so a slow or hostile source can't tie up the server indefinitely.
-const importCloneTimeout = 5 * time.Minute
+// ImportCloneTimeout bounds how long a one-shot import's git clone may run,
+// so a slow or hostile source can't tie up the server indefinitely. Exported
+// so adminrpc's Client can give the RPC call itself a matching (slightly
+// longer) deadline — the default per-call RPC timeout is tuned for the
+// local, near-instant operations everything else in this package does.
+const ImportCloneTimeout = 5 * time.Minute
// ImportRepo one-shot-clones sourceURL into a new bare repo, registered like
// any other repo and owned by "<ownerUsername>@<localDomain>" — see
@@ -292,7 +295,7 @@ func (a *Admin) ImportRepo(name, sourceURL, ownerUsername string) error {
return err
}
- ctx, cancel := context.WithTimeout(context.Background(), importCloneTimeout)
+ ctx, cancel := context.WithTimeout(context.Background(), ImportCloneTimeout)
defer cancel()
if err := gitexec.CloneMirror(ctx, path, sourceURL); err != nil {
return err
internal/adminrpc/client.go
@@ -31,12 +31,20 @@ func (c *Client) Ping() error {
}
func (c *Client) call(method string, args any, out any) error {
- conn, err := net.DialTimeout("unix", c.socketPath, c.timeout)
+ return c.callWithTimeout(method, args, out, c.timeout)
+}
+
+// callWithTimeout is call with an explicit deadline, for the rare RPC method
+// (currently just ImportRepo) whose underlying operation can legitimately
+// run far longer than every other admin call, which are all local and
+// near-instant.
+func (c *Client) callWithTimeout(method string, args any, out any, timeout time.Duration) error {
+ conn, err := net.DialTimeout("unix", c.socketPath, timeout)
if err != nil {
return err
}
defer conn.Close()
- _ = conn.SetDeadline(time.Now().Add(c.timeout))
+ _ = conn.SetDeadline(time.Now().Add(timeout))
if err := json.NewEncoder(conn).Encode(request{Method: method, Args: args}); err != nil {
return err
@@ -108,8 +116,14 @@ func (c *Client) CreateRepo(name, ownerUsername string) error {
return err
}
+// importRPCTimeout gives the RPC call itself a bit more headroom than
+// admin.ImportCloneTimeout, so the server-side clone's own timeout is always
+// what actually fires first — the client-side deadline is a backstop, not
+// the primary bound.
+const importRPCTimeout = admin.ImportCloneTimeout + 30*time.Second
+
func (c *Client) ImportRepo(name, sourceURL, ownerUsername string) error {
- return c.call(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil)
+ return c.callWithTimeout(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil, importRPCTimeout)
}
func (c *Client) DeleteRepo(name string) error {