Skip to content

Commit 2932b0f

Browse files
authored
feat: deployment stability, nssm validation, and frontend ux improvements (#7)
1 parent 5e749c2 commit 2932b0f

6 files changed

Lines changed: 109 additions & 43 deletions

File tree

internal/agent/deploy.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,19 @@ func (d *Deployer) ensureServiceByType(svc ServiceConfig, currentDir string) err
247247
d.l("static service -- no NSSM registration needed", "name", svc.WindowsServiceName)
248248
return nil
249249
default: // "nssm"
250+
if svc.BinaryName == "" {
251+
return fmt.Errorf("binary_name is empty for service %s — cannot register with NSSM", svc.WindowsServiceName)
252+
}
250253
newBin := filepath.Join(currentDir, svc.BinaryName)
254+
if _, err := os.Stat(newBin); os.IsNotExist(err) {
255+
// List what's actually in the directory to help debug
256+
entries, _ := os.ReadDir(currentDir)
257+
var names []string
258+
for _, e := range entries {
259+
names = append(names, e.Name())
260+
}
261+
return fmt.Errorf("binary %q not found in %s (available: %v)", svc.BinaryName, currentDir, names)
262+
}
251263
return d.ensureService(svc, newBin)
252264
}
253265
}
@@ -284,7 +296,7 @@ func (d *Deployer) ensureService(svc ServiceConfig, binPath string) error {
284296
{"AppRestartDelay", "5000"},
285297
}
286298
if svc.EnvFile != "" {
287-
settings = append(settings, []string{"AppEnvExtra", "ENV_FILE=" + svc.EnvFile})
299+
settings = append(settings, []string{"AppEnvironmentExtra", "ENV_FILE=" + svc.EnvFile})
288300
}
289301

290302
for _, kv := range settings {

internal/agent/state.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,14 @@ func (s *StateManager) RecordPollEvent(status, remoteVersion, errMsg string) {
181181
ORDER BY id DESC
182182
LIMIT 50
183183
)`, s.watcherID, s.watcherID)
184+
}
185+
186+
// ConsecutiveFailuresForVersion returns the number of consecutive failed deploys
187+
// for a specific version. Used to prevent infinite deploy retries.
188+
func (s *StateManager) ConsecutiveFailuresForVersion(version string) int {
189+
var count int64
190+
s.db.Model(&database.DeployLog{}).
191+
Where("watcher_id = ? AND version = ? AND status = ?", s.watcherID, version, string(StatusFailed)).
192+
Count(&count)
193+
return int(count)
184194
}

internal/agent/watcher.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ func (r *RepoWatcher) Run(ctx context.Context) error {
120120

121121
meta, err := r.github.FetchMetadata(ctx, r.wcfg.MetadataURL)
122122
if err != nil {
123-
r.state.RecordPollEvent("error", "", err.Error())
123+
if !errors.Is(err, context.Canceled) {
124+
r.state.RecordPollEvent("error", "", err.Error())
125+
}
124126
return fmt.Errorf("fetch metadata: %w", err)
125127
}
126128

@@ -136,7 +138,9 @@ func (r *RepoWatcher) Run(ctx context.Context) error {
136138

137139
localVersion, err := r.state.ReadVersion()
138140
if err != nil {
139-
r.state.RecordPollEvent("error", targetVersion, "read local version: "+err.Error())
141+
if !errors.Is(err, context.Canceled) {
142+
r.state.RecordPollEvent("error", targetVersion, "read local version: "+err.Error())
143+
}
140144
return fmt.Errorf("read local version: %w", err)
141145
}
142146
r.log.Info("local version", "current", localVersion)
@@ -150,8 +154,21 @@ func (r *RepoWatcher) Run(ctx context.Context) error {
150154
r.state.RecordPollEvent("new_release", targetVersion, "")
151155
r.log.Info("version mismatch, deploying", "from", localVersion, "to", targetVersion)
152156

157+
// Prevent infinite deploy retries — cap at 3 consecutive failures for the same version.
158+
// A manual redeploy from the dashboard resets this by clearing current_version.
159+
const maxDeployRetries = 3
160+
failures := r.state.ConsecutiveFailuresForVersion(targetVersion)
161+
if failures >= maxDeployRetries {
162+
msg := fmt.Sprintf("deploy suspended for %s after %d consecutive failures — use dashboard to redeploy", targetVersion, failures)
163+
r.log.Warn(msg)
164+
r.state.RecordPollEvent("deploy_suspended", targetVersion, msg)
165+
return nil
166+
}
167+
153168
if err := r.deploy(ctx, svcMeta, targetVersion, localVersion); err != nil {
154-
_ = r.state.SetFailed(err.Error())
169+
if !errors.Is(err, context.Canceled) {
170+
_ = r.state.SetFailed(err.Error())
171+
}
155172
return fmt.Errorf("deploy: %w", err)
156173
}
157174

web/src/routes/services/[id]/+page.svelte

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@
66
import * as Table from '$lib/components/ui/table';
77
import * as Tabs from '$lib/components/ui/tabs';
88
import * as Button from '$lib/components/ui/button';
9-
import { ArrowLeft, Play, Square, RefreshCw, Heart, AlertCircle, CheckCircle2, XCircle, Activity, FileText, ExternalLink, TerminalSquare, Save } from '@lucide/svelte';
9+
import {
10+
ArrowLeft,
11+
Play,
12+
Square,
13+
RefreshCw,
14+
Heart,
15+
AlertCircle,
16+
CheckCircle2,
17+
XCircle,
18+
Activity,
19+
FileText,
20+
ExternalLink,
21+
TerminalSquare,
22+
Save
23+
} from '@lucide/svelte';
1024
import { goto } from '$app/navigation';
1125
1226
let service = $state<Service | null>(null);
@@ -19,10 +33,10 @@
1933
let logError = $state('');
2034
let logType = $state<'out' | 'err'>('out');
2135
let logCount = $state(100);
22-
36+
2337
let envContent = $state('');
2438
let savingEnv = $state(false);
25-
39+
2640
let activeTab = $state(page.url.searchParams.get('tab') || 'health');
2741
2842
const id = Number(page.params.id);
@@ -231,8 +245,14 @@
231245
<p class="mt-1 font-mono text-sm">
232246
{#if service.public_url}
233247
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
234-
<a href={service.public_url} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 hover:underline text-blue-400">
235-
{service.public_url} <ExternalLink class="h-3 w-3" />
248+
<a
249+
href={service.public_url}
250+
target="_blank"
251+
rel="noopener noreferrer"
252+
class="inline-flex items-center gap-1.5 text-blue-400 hover:underline"
253+
>
254+
{service.public_url}
255+
<ExternalLink class="h-3 w-3" />
236256
</a>
237257
{:else}
238258
@@ -244,18 +264,24 @@
244264

245265
{#if service.service_type === 'static'}
246266
<div class="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4">
247-
<div class="flex items-center gap-2 mb-2 text-blue-400 font-medium">
267+
<div class="mb-2 flex items-center gap-2 font-medium text-blue-400">
248268
<TerminalSquare class="h-5 w-5" />
249269
IIS Configuration Required
250270
</div>
251-
<p class="text-sm text-foreground/80 mb-4">
252-
The Watcher automatically manages extracting releases and updating target junctions for static sites, but it does <strong>not</strong> create the IIS website itself. Run these commands once in an Administrator PowerShell to link IIS to this deployment:
271+
<p class="mb-4 text-sm text-foreground/80">
272+
The Watcher automatically manages extracting releases and updating target junctions for
273+
static sites, but it does <strong>not</strong> create the IIS website itself. Run these commands
274+
once in an Administrator PowerShell to link IIS to this deployment:
253275
</p>
254-
<div class="bg-black/50 p-3 rounded-md overflow-x-auto border border-border">
255-
<pre class="font-mono text-xs text-blue-300 leading-relaxed max-w-full"><span class="text-muted-foreground"># 1. Create the application pool</span>
276+
<div class="overflow-x-auto rounded-md border border-border bg-black/50 p-3">
277+
<pre class="max-w-full font-mono text-xs leading-relaxed text-blue-300"><span
278+
class="text-muted-foreground"># 1. Create the application pool</span
279+
>
256280
appcmd.exe add apppool /name:"{service.iis_app_pool}"
257281

258-
<span class="text-muted-foreground"># 2. Create the site (change the port/host binding as needed)</span>
282+
<span class="text-muted-foreground"
283+
># 2. Create the site (change the port/host binding as needed)</span
284+
>
259285
appcmd.exe add site /name:"{service.iis_site_name}" /bindings:http/*:8080: /physicalPath:"{watcher?.install_dir}\current"
260286

261287
<span class="text-muted-foreground"># 3. Assign the site to the application pool</span>
@@ -265,7 +291,12 @@ appcmd.exe set app "{service.iis_site_name}/" /applicationPool:"{service.iis_app
265291
{/if}
266292

267293
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
268-
<Tabs.Root bind:value={activeTab} onValueChange={(v) => { if (v) goto(`?tab=${v}`, { replaceState: true, keepFocus: true, noScroll: true }); }}>
294+
<Tabs.Root
295+
bind:value={activeTab}
296+
onValueChange={(v) => {
297+
if (v) goto(`?tab=${v}`, { replaceState: true, keepFocus: true, noScroll: true });
298+
}}
299+
>
269300
<Tabs.List>
270301
<Tabs.Trigger value="health">Health History ({healthHistory.length})</Tabs.Trigger>
271302
<Tabs.Trigger value="logs">Logs</Tabs.Trigger>
@@ -376,29 +407,33 @@ appcmd.exe set app "{service.iis_site_name}/" /applicationPool:"{service.iis_app
376407
</Card.Content>
377408
</Card.Root>
378409
</Tabs.Content>
379-
|
410+
380411
<!-- Environment -->
381412
<Tabs.Content value="env" class="mt-4">
382413
<Card.Root class="border-border bg-card">
383414
<Card.Header class="pb-3">
384415
<div class="flex items-center justify-between">
385416
<div class="space-y-1">
386417
<Card.Title class="text-lg">Environment Variables</Card.Title>
387-
<Card.Description>Edit the <code>{service.env_file || '.env'}</code> file for this service.</Card.Description>
418+
<Card.Description
419+
>Edit the <code>{service.env_file || '.env'}</code> file for this service.</Card.Description
420+
>
388421
</div>
389422
<div class="flex items-center gap-2">
390423
<Button.Root variant="outline" size="sm" onclick={saveEnv} disabled={savingEnv}>
391-
{#if savingEnv}<RefreshCw class="mr-2 h-4 w-4 animate-spin" />{:else}<Save class="mr-2 h-4 w-4" />{/if}
424+
{#if savingEnv}<RefreshCw class="mr-2 h-4 w-4 animate-spin" />{:else}<Save
425+
class="mr-2 h-4 w-4"
426+
/>{/if}
392427
Save
393428
</Button.Root>
394-
<Button.Root
395-
variant="default"
396-
size="sm"
397-
onclick={() => {
429+
<Button.Root
430+
variant="default"
431+
size="sm"
432+
onclick={() => {
398433
saveEnv().then(() => runAction(() => api.restartService(id)));
399-
}}
434+
}}
400435
disabled={savingEnv}
401-
class="bg-amber-600 hover:bg-amber-700 text-white"
436+
class="bg-amber-600 text-white hover:bg-amber-700"
402437
>
403438
<RefreshCw class="mr-2 h-4 w-4" /> Save & Restart
404439
</Button.Root>
@@ -408,16 +443,17 @@ appcmd.exe set app "{service.iis_site_name}/" /applicationPool:"{service.iis_app
408443
<Card.Content>
409444
<textarea
410445
bind:value={envContent}
411-
class="min-h-[400px] w-full rounded-md border border-border bg-black/50 p-4 font-mono text-sm text-blue-300 focus:outline-none focus:ring-1 focus:ring-blue-500/50"
446+
class="min-h-[400px] w-full rounded-md border border-border bg-black/50 p-4 font-mono text-sm text-blue-300 focus:ring-1 focus:ring-blue-500/50 focus:outline-none"
412447
placeholder="KEY=VALUE"
413448
></textarea>
414449
<p class="mt-2 text-xs text-muted-foreground italic">
415-
Note: Environment variables are written to <code>{service.env_file}</code> in the service's installation directory.
450+
Note: Environment variables are written to <code>{service.env_file}</code> in the service's
451+
installation directory.
416452
</p>
417453
</Card.Content>
418454
</Card.Root>
419455
</Tabs.Content>
420-
|
456+
421457
<!-- Deploys -->
422458
<Tabs.Content value="deploys" class="mt-4">
423459
{#if deploys.length > 0}

web/src/routes/watchers/+page.svelte

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
formServices = [{
7272
service_type: 'nssm',
7373
windows_service_name: formServiceName,
74-
binary_name: inspectResult?.assets[0] || 'app.exe',
74+
binary_name: formServiceName ? `${formServiceName}.exe` : 'app.exe',
7575
env_file: '.env',
7676
health_check_url: formHcURL,
7777
}];
@@ -124,7 +124,7 @@
124124
formServices = [...formServices, {
125125
service_type: 'nssm',
126126
windows_service_name: `${formServiceName}-extra`,
127-
binary_name: inspectResult?.assets[0] || '',
127+
binary_name: formServiceName ? `${formServiceName}-extra.exe` : 'app.exe',
128128
env_file: '.env',
129129
}];
130130
}
@@ -386,7 +386,7 @@
386386
</Dialog.Footer>
387387
{:else if createStep === 3}
388388
<div class="space-y-4">
389-
{#each formServices as svc, i}
389+
{#each formServices as svc, i (i)}
390390
<div class="border rounded-md p-3 space-y-3 relative bg-card">
391391
<Button.Root variant="ghost" size="icon" class="absolute top-2 right-2 h-6 w-6 text-red-400" type="button" onclick={() => removeServiceDraft(i)}>
392392
<Trash2 class="h-3 w-3" />
@@ -406,17 +406,8 @@
406406
<Input class="h-8 text-xs" bind:value={svc.windows_service_name} placeholder="myapp-web" />
407407
</div>
408408
<div class="space-y-1">
409-
<Label class="text-xs">Asset Name (Exact in release)</Label>
410-
{#if inspectResult?.assets && inspectResult.assets.length > 0}
411-
<select bind:value={svc.binary_name} class="w-full text-xs rounded border bg-transparent p-2">
412-
<option value="">Select asset...</option>
413-
{#each inspectResult.assets as asset}
414-
<option value={asset}>{asset}</option>
415-
{/each}
416-
</select>
417-
{:else}
418-
<Input class="h-8 text-xs" bind:value={svc.binary_name} placeholder="myapp.exe" />
419-
{/if}
409+
<Label class="text-xs">Executable Name</Label>
410+
<Input class="h-8 text-xs" bind:value={svc.binary_name} placeholder="myapp.exe" />
420411
</div>
421412
<div class="space-y-1">
422413
<Label class="text-xs">Env file relative path</Label>

workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ jobs:
299299
Write-Host "Installing service: $($svc.Name)"
300300
& $NSSM install $svc.Name $svc.Exe
301301
& $NSSM set $svc.Name AppDirectory $BaseDir
302-
& $NSSM set $svc.Name AppEnvExtra "ENV_FILE=$($svc.EnvFile)"
302+
& $NSSM set $svc.Name AppEnvironmentExtra "ENV_FILE=$($svc.EnvFile)"
303303
& $NSSM set $svc.Name Start SERVICE_AUTO_START
304304
& $NSSM set $svc.Name AppStdout "$BaseDir\logs\$($svc.Name).out.log"
305305
& $NSSM set $svc.Name AppStderr "$BaseDir\logs\$($svc.Name).err.log"

0 commit comments

Comments
 (0)