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("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/status", s.serverManage(s.handleServerStatusAPI))
|
||||||
mux.Handle("GET /api/server/docker-status", s.serverManage(s.handleDockerStatusAPI))
|
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/restart-server", s.serverManage(s.handleRestartServerAPI))
|
||||||
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
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
|
// compose ps" failure (daemon not running, compose file missing) is
|
||||||
// reported as an API error rather than an empty list, so the frontend can
|
// reported as an API error rather than an empty list, so the frontend can
|
||||||
// tell "no services" apart from "couldn't ask Docker".
|
// 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) {
|
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
services, err := s.serverCtl.DockerStatus(r.Context())
|
services, err := s.serverCtl.DockerStatus(r.Context())
|
||||||
if err != nil {
|
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>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-X1cMLS10.js"></script>
|
<script type="module" crossorigin src="/assets/index-qfdwgPZE.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-q5XsR54I.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dc3900m_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ export const api = {
|
||||||
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
||||||
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
||||||
dockerStatus: () => request<DockerService[]>("/api/server/docker-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, {
|
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { api, errorMessage } from "../api";
|
||||||
import type { CommandResult } from "../types";
|
import type { CommandResult } from "../types";
|
||||||
import { Alert, JsonBlock } from "./ui";
|
import { Alert, JsonBlock } from "./ui";
|
||||||
|
|
||||||
type ActionTone = "neutral" | "warn" | "danger";
|
type ActionTone = "neutral" | "warn" | "danger" | "primary";
|
||||||
|
|
||||||
export function ActionButton({
|
export function ActionButton({
|
||||||
label,
|
label,
|
||||||
|
|
@ -77,7 +77,7 @@ export function ActionButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
const canConfirm = result?.dry_run && !result.error;
|
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(() => {
|
const previewPayload = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return payload();
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
|
|
@ -69,25 +69,25 @@ function IdentitySection() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section-block">
|
<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>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{!identity ? (
|
{!identity ? (
|
||||||
<LoadingSurface label={"Loading identity..."} />
|
<LoadingSurface label={"Loading identity..."} />
|
||||||
) : (
|
) : (
|
||||||
<div className="card-body">
|
<div className="card-body identity-card">
|
||||||
<div className="entity-head-main">
|
<div className="identity-layout">
|
||||||
<div className="avatar-edit-slot">
|
<div className="avatar-edit-slot">
|
||||||
{identity.icon_ext && !iconFailed ? (
|
{identity.icon_ext && !iconFailed ? (
|
||||||
<img
|
<img
|
||||||
className="avatar-photo-img"
|
className="avatar-photo-img"
|
||||||
src={api.serverIconURL() + `&b=${iconBust}`}
|
src={api.serverIconURL() + `&b=${iconBust}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 56, height: 56 }}
|
style={{ width: 88, height: 88 }}
|
||||||
onError={() => setIconFailed(true)}
|
onError={() => setIconFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
|
<div className="avatar-fallback server-icon-fallback" style={{ width: 88, height: 88 }}>
|
||||||
<ImageOff size={20} />
|
<ImageOff size={26} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
@ -97,15 +97,15 @@ function IdentitySection() {
|
||||||
title={"Change server icon"}
|
title={"Change server icon"}
|
||||||
onClick={() => setIconModalOpen(true)}
|
onClick={() => setIconModalOpen(true)}
|
||||||
>
|
>
|
||||||
<ImagePlus size={13} />
|
<ImagePlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="server-identity-fields">
|
<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>{"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>
|
</div>
|
||||||
<div className="gift-table-actions">
|
<div className="gift-table-actions identity-save-row">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
label={"Save identity"}
|
label={"Save identity"}
|
||||||
|
|
@ -471,6 +471,75 @@ function dockerStatusLabel(service: DockerService): string {
|
||||||
// couple seconds for no reason.
|
// couple seconds for no reason.
|
||||||
const LIVE_POLL_MS = 4000;
|
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() {
|
function ServicesTab() {
|
||||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||||
const [statusError, setStatusError] = useState("");
|
const [statusError, setStatusError] = useState("");
|
||||||
|
|
@ -503,48 +572,46 @@ function ServicesTab() {
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const loading = status === null && docker === null && !statusError && !dockerError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
title={"Docker services"}
|
title={"Services"}
|
||||||
text={"postgres / redis / minio, from deploy/docker-compose.yml -- refreshes automatically."}
|
action={
|
||||||
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh"}</button>}
|
<div className="services-header-actions">
|
||||||
|
<UpdateButton
|
||||||
|
onUpdateStarted={(label) => {
|
||||||
|
setOverlayLabel(label);
|
||||||
|
void restartWatcher.watch();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{dockerError && <Alert>{dockerError}</Alert>}
|
<ActionButton
|
||||||
{docker === null && !dockerError ? (
|
compact
|
||||||
<LoadingSurface label={"Loading Docker status..."} />
|
tone="primary"
|
||||||
) : docker && docker.length > 0 ? (
|
label={"Restart"}
|
||||||
<div className="service-grid">
|
path="/api/actions/restart-server"
|
||||||
{docker.map((service) => (
|
payload={() => ({})}
|
||||||
<ServiceCard
|
onDone={() => {
|
||||||
key={service.name}
|
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
||||||
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
|
void restartWatcher.watch();
|
||||||
name={service.name}
|
}}
|
||||||
tone={dockerTone(service)}
|
|
||||||
statusLabel={dockerStatusLabel(service)}
|
|
||||||
detail={service.state}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</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>}
|
{statusError && <Alert>{statusError}</Alert>}
|
||||||
{status === null && !statusError ? (
|
{dockerError && <Alert>{dockerError}</Alert>}
|
||||||
<LoadingSurface label={"Loading process status..."} />
|
{loading ? (
|
||||||
) : status ? (
|
<LoadingSurface label={"Loading service status..."} />
|
||||||
|
) : (
|
||||||
<div className="service-grid">
|
<div className="service-grid">
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
<ServiceCard
|
<ServiceCard
|
||||||
icon={<Server size={18} />}
|
icon={<Server size={18} />}
|
||||||
name={"owpengram-server"}
|
name={"Server"}
|
||||||
tone={status.ServerAlive ? "good" : "danger"}
|
tone={status.ServerAlive ? "good" : "danger"}
|
||||||
statusLabel={status.ServerAlive ? "running" : "stopped"}
|
statusLabel={status.ServerAlive ? "running" : "stopped"}
|
||||||
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
|
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
|
||||||
|
|
@ -556,30 +623,20 @@ function ServicesTab() {
|
||||||
statusLabel={status.AdminAlive ? "running" : "stopped"}
|
statusLabel={status.AdminAlive ? "running" : "stopped"}
|
||||||
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
|
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
) : null}
|
)}
|
||||||
<div className="gift-table-actions">
|
{docker?.map((service) => (
|
||||||
<ActionButton
|
<ServiceCard
|
||||||
tone="warn"
|
key={service.name}
|
||||||
label={"Restart server"}
|
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
|
||||||
path="/api/actions/restart-server"
|
name={service.name}
|
||||||
payload={() => ({})}
|
tone={dockerTone(service)}
|
||||||
onDone={() => {
|
statusLabel={dockerStatusLabel(service)}
|
||||||
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
detail={service.state}
|
||||||
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();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
||||||
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
||||||
|
|
|
||||||
|
|
@ -947,6 +947,35 @@
|
||||||
gap: 8px;
|
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 {
|
.server-icon-fallback {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--panel-subtle);
|
background: var(--panel-subtle);
|
||||||
|
|
@ -1181,3 +1210,9 @@
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
border: 1px solid var(--line);
|
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
|
return false
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
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 {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(out), strconv.Itoa(pid))
|
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
|
// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
|
||||||
|
|
@ -162,12 +166,18 @@ func killPID(pid int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
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
|
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)
|
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
|
// 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.Stdout = logf
|
||||||
cmd.Stderr = logf
|
cmd.Stderr = logf
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
|
hideWindow(cmd)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return 0, fmt.Errorf("start %s: %w", exePath, err)
|
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_PROJECT="+st.DockerProject,
|
||||||
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
||||||
)
|
)
|
||||||
|
hideWindow(cmd)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
log := "$ docker compose up -d\n" + string(out)
|
log := "$ docker compose up -d\n" + string(out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -233,6 +245,7 @@ func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
|
||||||
deadline := time.Now().Add(postgresWaitTimeout)
|
deadline := time.Now().Add(postgresWaitTimeout)
|
||||||
for {
|
for {
|
||||||
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
|
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
|
||||||
|
hideWindow(pgCmd)
|
||||||
if pgCmd.Run() == nil {
|
if pgCmd.Run() == nil {
|
||||||
return log + "\nPostgreSQL ready\n", 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_PROJECT="+st.DockerProject,
|
||||||
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
||||||
)
|
)
|
||||||
|
hideWindow(cmd)
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("docker compose ps failed: %w", err)
|
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
|
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 ---------------------------------------------------------
|
// --- build steps ---------------------------------------------------------
|
||||||
|
|
||||||
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
// 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) {
|
func (m *Manager) GitPull(ctx context.Context) (string, error) {
|
||||||
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
|
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
|
||||||
cmd.Dir = m.Root
|
cmd.Dir = m.Root
|
||||||
|
hideWindow(cmd)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
log := "$ git pull --ff-only\n" + string(out)
|
log := "$ git pull --ff-only\n" + string(out)
|
||||||
return log, err
|
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 := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
|
||||||
cmd.Dir = m.Root
|
cmd.Dir = m.Root
|
||||||
|
hideWindow(cmd)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
|
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