improved settings window
This commit is contained in:
parent
80bc8d352c
commit
1c9a192a96
13 changed files with 271 additions and 89 deletions
|
|
@ -182,6 +182,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
||||
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
|
||||
mux.Handle("GET /api/server/docker-status", s.serverManage(s.handleDockerStatusAPI))
|
||||
mux.Handle("GET /api/server/check-updates", s.serverManage(s.handleCheckServerUpdatesAPI))
|
||||
mux.Handle("POST /api/actions/restart-server", s.serverManage(s.handleRestartServerAPI))
|
||||
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
|
|
|||
|
|
@ -228,6 +228,18 @@ func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|||
// compose ps" failure (daemon not running, compose file missing) is
|
||||
// reported as an API error rather than an empty list, so the frontend can
|
||||
// tell "no services" apart from "couldn't ask Docker".
|
||||
// handleCheckServerUpdatesAPI backs the Update button's "Check updates"
|
||||
// state -- a plain git fetch + rev-list count, no pull/build/restart. See
|
||||
// procctl.Manager.CheckUpdates.
|
||||
func (s *server) handleCheckServerUpdatesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
behind, err := s.serverCtl.CheckUpdates(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"commits_behind": behind})
|
||||
}
|
||||
|
||||
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := s.serverCtl.DockerStatus(r.Context())
|
||||
if err != nil {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-qfdwgPZE.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-qfdwgPZE.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-X1cMLS10.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-q5XsR54I.css">
|
||||
<script type="module" crossorigin src="/assets/index-qfdwgPZE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dc3900m_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ export const api = {
|
|||
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
||||
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
||||
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
|
||||
checkServerUpdates: () => request<{ commits_behind: number }>("/api/server/check-updates"),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api, errorMessage } from "../api";
|
|||
import type { CommandResult } from "../types";
|
||||
import { Alert, JsonBlock } from "./ui";
|
||||
|
||||
type ActionTone = "neutral" | "warn" | "danger";
|
||||
type ActionTone = "neutral" | "warn" | "danger" | "primary";
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
|
|
@ -77,7 +77,7 @@ export function ActionButton({
|
|||
}
|
||||
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : tone === "primary" ? "primary" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const previewPayload = useMemo(() => {
|
||||
try {
|
||||
return payload();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, Trash2, Upload, X } from "lucide-react";
|
||||
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, Download, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, Trash2, Upload, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
|
|
@ -69,25 +69,25 @@ function IdentitySection() {
|
|||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Server identity"} text={"Shown to clients auto-fetching this server's key -- takes effect immediately, no restart needed."} />
|
||||
<SectionHead title={"Server identity"} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!identity ? (
|
||||
<LoadingSurface label={"Loading identity..."} />
|
||||
) : (
|
||||
<div className="card-body">
|
||||
<div className="entity-head-main">
|
||||
<div className="card-body identity-card">
|
||||
<div className="identity-layout">
|
||||
<div className="avatar-edit-slot">
|
||||
{identity.icon_ext && !iconFailed ? (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={api.serverIconURL() + `&b=${iconBust}`}
|
||||
alt=""
|
||||
style={{ width: 56, height: 56 }}
|
||||
style={{ width: 88, height: 88 }}
|
||||
onError={() => setIconFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
|
||||
<ImageOff size={20} />
|
||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 88, height: 88 }}>
|
||||
<ImageOff size={26} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -97,15 +97,15 @@ function IdentitySection() {
|
|||
title={"Change server icon"}
|
||||
onClick={() => setIconModalOpen(true)}
|
||||
>
|
||||
<ImagePlus size={13} />
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-identity-fields">
|
||||
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} onChange={(event) => setName(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Description"}</span><input value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Description"}</span><textarea rows={4} value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gift-table-actions">
|
||||
<div className="gift-table-actions identity-save-row">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
label={"Save identity"}
|
||||
|
|
@ -471,6 +471,75 @@ function dockerStatusLabel(service: DockerService): string {
|
|||
// couple seconds for no reason.
|
||||
const LIVE_POLL_MS = 4000;
|
||||
|
||||
// UpdateButton is a two-state control: "Check updates" (a plain git fetch +
|
||||
// commit count, no side effects) until a check finds the branch behind its
|
||||
// upstream, at which point it becomes "Update (<N>)" -- a second click runs
|
||||
// the real git pull + rebuild + restart (via the existing update-server
|
||||
// action, called directly with confirm:true since the check step already
|
||||
// serves as the "are you sure" gate). Auto-checks once on mount so the
|
||||
// button reflects reality without an operator having to click twice.
|
||||
function UpdateButton({ onUpdateStarted }: { onUpdateStarted: (overlayLabel: string) => void }) {
|
||||
const [behind, setBehind] = useState<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const check = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await api.checkServerUpdates();
|
||||
setBehind(result.commits_behind);
|
||||
setMessage(result.commits_behind > 0
|
||||
? `${result.commits_behind} new commit${result.commits_behind === 1 ? "" : "s"} pulled from GitHub.`
|
||||
: "Already up to date.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void check(); }, [check]);
|
||||
|
||||
async function runUpdate() {
|
||||
const commits = behind ?? 0;
|
||||
if (!window.confirm(`Pull ${commits} commit(s), rebuild, and restart owpengram-server and the admin panel?`)) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/update-server", { command_id: "", reason: "Update via Services tab", confirm: true });
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
onUpdateStarted(`Pulled ${commits} commit${commits === 1 ? "" : "s"} -- rebuilding and restarting owpengram-server and the admin panel...`);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasUpdates = (behind ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn compact-btn icon-text ${hasUpdates ? "danger" : ""}`}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
title={error || message || undefined}
|
||||
onClick={() => void (hasUpdates ? runUpdate() : check())}
|
||||
>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : hasUpdates ? <Download size={15} /> : <RefreshCw size={15} />}
|
||||
{hasUpdates ? `Update (${behind})` : "Check updates"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTab() {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState("");
|
||||
|
|
@ -503,48 +572,46 @@ function ServicesTab() {
|
|||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const loading = status === null && docker === null && !statusError && !dockerError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Docker services"}
|
||||
text={"postgres / redis / minio, from deploy/docker-compose.yml -- refreshes automatically."}
|
||||
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh"}</button>}
|
||||
title={"Services"}
|
||||
action={
|
||||
<div className="services-header-actions">
|
||||
<UpdateButton
|
||||
onUpdateStarted={(label) => {
|
||||
setOverlayLabel(label);
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
/>
|
||||
{dockerError && <Alert>{dockerError}</Alert>}
|
||||
{docker === null && !dockerError ? (
|
||||
<LoadingSurface label={"Loading Docker status..."} />
|
||||
) : docker && docker.length > 0 ? (
|
||||
<div className="service-grid">
|
||||
{docker.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.name}
|
||||
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
|
||||
name={service.name}
|
||||
tone={dockerTone(service)}
|
||||
statusLabel={dockerStatusLabel(service)}
|
||||
detail={service.state}
|
||||
<ActionButton
|
||||
compact
|
||||
tone="primary"
|
||||
label={"Restart"}
|
||||
path="/api/actions/restart-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => {
|
||||
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : docker && docker.length === 0 ? (
|
||||
<Alert>{"No Docker Compose services found (deploy/docker-compose.yml missing, or nothing has been started yet)."}</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Process control"}
|
||||
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
|
||||
}
|
||||
/>
|
||||
{statusError && <Alert>{statusError}</Alert>}
|
||||
{status === null && !statusError ? (
|
||||
<LoadingSurface label={"Loading process status..."} />
|
||||
) : status ? (
|
||||
{dockerError && <Alert>{dockerError}</Alert>}
|
||||
{loading ? (
|
||||
<LoadingSurface label={"Loading service status..."} />
|
||||
) : (
|
||||
<div className="service-grid">
|
||||
{status && (
|
||||
<>
|
||||
<ServiceCard
|
||||
icon={<Server size={18} />}
|
||||
name={"owpengram-server"}
|
||||
name={"Server"}
|
||||
tone={status.ServerAlive ? "good" : "danger"}
|
||||
statusLabel={status.ServerAlive ? "running" : "stopped"}
|
||||
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
|
||||
|
|
@ -556,30 +623,20 @@ function ServicesTab() {
|
|||
statusLabel={status.AdminAlive ? "running" : "stopped"}
|
||||
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Restart server"}
|
||||
path="/api/actions/restart-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => {
|
||||
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
tone="danger"
|
||||
label={"Update (git pull + rebuild + restart)"}
|
||||
path="/api/actions/update-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => {
|
||||
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
</>
|
||||
)}
|
||||
{docker?.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.name}
|
||||
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
|
||||
name={service.name}
|
||||
tone={dockerTone(service)}
|
||||
statusLabel={dockerStatusLabel(service)}
|
||||
detail={service.state}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
||||
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
||||
|
|
|
|||
|
|
@ -947,6 +947,35 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Server identity card: icon+upload fixed on the left, name+description
|
||||
filling the rest of the section's width on the right, full-width Save
|
||||
button spanning the whole section beneath. */
|
||||
.identity-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.identity-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.identity-layout .server-identity-fields {
|
||||
flex: 1 1 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.identity-layout .form-field textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.identity-save-row .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.server-icon-fallback {
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
|
|
@ -1181,3 +1210,9 @@
|
|||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.services-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
|
|||
9
internal/procctl/hidewindow_other.go
Normal file
9
internal/procctl/hidewindow_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !windows
|
||||
|
||||
package procctl
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// hideWindow is a no-op on non-Windows -- there is no console window to
|
||||
// suppress. See hidewindow_windows.go for why this exists at all.
|
||||
func hideWindow(cmd *exec.Cmd) {}
|
||||
25
internal/procctl/hidewindow_windows.go
Normal file
25
internal/procctl/hidewindow_windows.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//go:build windows
|
||||
|
||||
package procctl
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// hideWindow stops the spawned process from popping a console window on
|
||||
// Windows. Every exec.Command in this package (tasklist, taskkill, docker,
|
||||
// git, go build) is a console-subsystem binary -- when the caller (this
|
||||
// admin panel process) has no console of its own to inherit (the normal
|
||||
// case once it's running detached, per procctl's own launch()), Windows
|
||||
// implicitly creates a brand new one for each child. With the live
|
||||
// Services-tab polling calling tasklist + docker compose ps every few
|
||||
// seconds, that showed up as a terminal window flashing on screen
|
||||
// repeatedly. CREATE_NO_WINDOW suppresses it without changing anything
|
||||
// about how the child runs or what output it produces.
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.HideWindow = true
|
||||
}
|
||||
|
|
@ -133,13 +133,17 @@ func pidAlive(pid int) bool {
|
|||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)).Output()
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), strconv.Itoa(pid))
|
||||
}
|
||||
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
|
||||
cmd := exec.Command("kill", "-0", strconv.Itoa(pid))
|
||||
hideWindow(cmd)
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
|
||||
|
|
@ -162,12 +166,18 @@ func killPID(pid int) {
|
|||
return
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
|
||||
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F")
|
||||
hideWindow(cmd)
|
||||
_ = cmd.Run()
|
||||
return
|
||||
}
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
|
||||
term := exec.Command("kill", "-TERM", strconv.Itoa(pid))
|
||||
hideWindow(term)
|
||||
_ = term.Run()
|
||||
time.Sleep(time.Second)
|
||||
_ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run()
|
||||
kill := exec.Command("kill", "-KILL", strconv.Itoa(pid))
|
||||
hideWindow(kill)
|
||||
_ = kill.Run()
|
||||
}
|
||||
|
||||
// launch starts exePath detached, cwd=Root, stdout/stderr appended to
|
||||
|
|
@ -189,6 +199,7 @@ func (m *Manager) launch(exePath, logPath string) (int, error) {
|
|||
cmd.Stdout = logf
|
||||
cmd.Stderr = logf
|
||||
cmd.Stdin = nil
|
||||
hideWindow(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("start %s: %w", exePath, err)
|
||||
}
|
||||
|
|
@ -224,6 +235,7 @@ func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
|
|||
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
|
||||
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
||||
)
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
log := "$ docker compose up -d\n" + string(out)
|
||||
if err != nil {
|
||||
|
|
@ -233,6 +245,7 @@ func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
|
|||
deadline := time.Now().Add(postgresWaitTimeout)
|
||||
for {
|
||||
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
|
||||
hideWindow(pgCmd)
|
||||
if pgCmd.Run() == nil {
|
||||
return log + "\nPostgreSQL ready\n", nil
|
||||
}
|
||||
|
|
@ -284,6 +297,7 @@ func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
|
|||
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
|
||||
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
||||
)
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker compose ps failed: %w", err)
|
||||
|
|
@ -303,6 +317,32 @@ func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
|
|||
return services, nil
|
||||
}
|
||||
|
||||
// CheckUpdates fetches from the remote and reports how many commits the
|
||||
// local branch is behind its upstream, WITHOUT pulling or building anything
|
||||
// -- the Services tab's "Check updates" button uses this to decide whether
|
||||
// to offer a real Update (GitPull + rebuild + restart) or tell the operator
|
||||
// they're already current.
|
||||
func (m *Manager) CheckUpdates(ctx context.Context) (int, error) {
|
||||
fetchCmd := exec.CommandContext(ctx, "git", "fetch")
|
||||
fetchCmd.Dir = m.Root
|
||||
hideWindow(fetchCmd)
|
||||
if out, err := fetchCmd.CombinedOutput(); err != nil {
|
||||
return 0, fmt.Errorf("git fetch failed: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
countCmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD..@{upstream}")
|
||||
countCmd.Dir = m.Root
|
||||
hideWindow(countCmd)
|
||||
out, err := countCmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("git rev-list failed: %w", err)
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse commit count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// --- build steps ---------------------------------------------------------
|
||||
|
||||
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
||||
|
|
@ -310,6 +350,7 @@ func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
|
|||
func (m *Manager) GitPull(ctx context.Context) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
|
||||
cmd.Dir = m.Root
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
log := "$ git pull --ff-only\n" + string(out)
|
||||
return log, err
|
||||
|
|
@ -339,6 +380,7 @@ func (m *Manager) goBuild(ctx context.Context, outPath, pkg string) (string, err
|
|||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
|
||||
cmd.Dir = m.Root
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue