Skip to content

Commit ac62555

Browse files
authored
fix project details page and add local exec method for local testing (#2425)
* fix project details page and add local exec method for local testing * add loom and projects default
1 parent 7130439 commit ac62555

File tree

7 files changed

+252
-62
lines changed

7 files changed

+252
-62
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package service_clients
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"os/exec"
11+
)
12+
13+
type LocalExecJobClient struct {}
14+
15+
// TriggerProjectsRefreshLocal starts a local binary with the required environment.
16+
// Binary path is taken from PROJECTS_REFRESH_BIN (fallback: ./projects-refresh-service).
17+
// It does NOT wait for completion; it returns as soon as the process starts successfully.
18+
func (f LocalExecJobClient) TriggerProjectsRefreshService(
19+
cloneUrl, branch, githubToken, repoFullName, orgId string,
20+
) (*BackgroundJobTriggerResponse, error) {
21+
22+
slog.Debug("starting local projects-refresh-service job")
23+
24+
// Resolve binary path from env or default.
25+
bin := os.Getenv("PROJECTS_REFRESH_BIN")
26+
if bin == "" {
27+
bin = "../../background/projects-refresh-service/projects_refesh_main"
28+
}
29+
30+
// Optional: working directory (set via env if you want), otherwise current dir.
31+
workingDir := os.Getenv("PROJECTS_REFRESH_WORKDIR")
32+
if workingDir == "" {
33+
wd, _ := os.Getwd()
34+
workingDir = wd
35+
}
36+
37+
// Build environment for the child process.
38+
// Keep existing env and append required vars.
39+
env := append(os.Environ(),
40+
"DIGGER_GITHUB_REPO_CLONE_URL="+cloneUrl,
41+
"DIGGER_GITHUB_REPO_CLONE_BRANCH="+branch,
42+
"DIGGER_GITHUB_TOKEN="+githubToken,
43+
"DIGGER_REPO_FULL_NAME="+repoFullName,
44+
"DIGGER_ORG_ID="+orgId,
45+
"DATABASE_URL="+os.Getenv("DATABASE_URL"),
46+
)
47+
48+
// Optional: add any tuning flags you previously used in the container world.
49+
// env = append(env, "GODEBUG=off", "GOFIPS140=off")
50+
51+
// If your binary needs args, add them here. Empty for now.
52+
cmd := exec.Command(bin)
53+
cmd.Dir = workingDir
54+
cmd.Env = env
55+
56+
// Pipe stdout/stderr to slog for observability.
57+
stdout, err := cmd.StdoutPipe()
58+
if err != nil {
59+
slog.Error("allocating stdout pipe failed", "error", err)
60+
return nil, err
61+
}
62+
stderr, err := cmd.StderrPipe()
63+
if err != nil {
64+
slog.Error("allocating stderr pipe failed", "error", err)
65+
return nil, err
66+
}
67+
68+
// Start process.
69+
if err := cmd.Start(); err != nil {
70+
slog.Error("failed to start local job", "binary", bin, "dir", workingDir, "error", err)
71+
return nil, err
72+
}
73+
74+
// Stream logs in background goroutines tied to a short-lived context so we don't leak.
75+
ctx, cancel := context.WithCancel(context.Background())
76+
go pipeToSlog(ctx, stdout, slog.LevelInfo, "projects-refresh")
77+
go pipeToSlog(ctx, stderr, slog.LevelError, "projects-refresh")
78+
79+
// Optionally, you can watch for process exit in a goroutine if you want to log completion.
80+
go func() {
81+
defer cancel()
82+
waitErr := cmd.Wait()
83+
if waitErr != nil {
84+
slog.Error("local job exited with error", "pid", cmd.Process.Pid, "error", waitErr)
85+
return
86+
}
87+
slog.Info("local job completed", "pid", cmd.Process.Pid)
88+
}()
89+
90+
slog.Debug("triggered local projects refresh", "pid", cmd.Process.Pid, "binary", bin, "workdir", workingDir)
91+
92+
return &BackgroundJobTriggerResponse{ID: fmt.Sprintf("%d", cmd.Process.Pid)}, nil
93+
}
94+
95+
// pipeToSlog streams a reader line-by-line into slog at the given level.
96+
func pipeToSlog(ctx context.Context, r io.Reader, level slog.Level, comp string) {
97+
br := bufio.NewScanner(r)
98+
// Increase the Scanner buffer in case the tool emits long lines.
99+
buf := make([]byte, 0, 64*1024)
100+
br.Buffer(buf, 10*1024*1024)
101+
102+
for br.Scan() {
103+
select {
104+
case <-ctx.Done():
105+
return
106+
default:
107+
slog.Log(context.Background(), level, br.Text(), "component", comp)
108+
}
109+
}
110+
if err := br.Err(); err != nil {
111+
slog.Error("log stream error", "component", comp, "error", err)
112+
}
113+
}

backend/service_clients/router.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ import (
77

88
func GetBackgroundJobsClient() (BackgroundJobsClient, error) {
99
clientType := os.Getenv("BACKGROUND_JOBS_CLIENT_TYPE")
10-
if clientType == "k8s" {
11-
clientSet, err := newInClusterClient()
12-
if err != nil {
13-
return nil, fmt.Errorf("error creating k8s client: %v", err)
14-
}
15-
return K8sJobClient{
16-
clientset: clientSet,
17-
namespace: "opentaco",
18-
}, nil
19-
} else {
20-
return FlyIOMachineJobClient{}, nil
10+
switch clientType {
11+
case "k8s":
12+
clientSet, err := newInClusterClient()
13+
if err != nil {
14+
return nil, fmt.Errorf("error creating k8s client: %v", err)
15+
}
16+
return K8sJobClient{
17+
clientset: clientSet,
18+
namespace: "opentaco",
19+
}, nil
20+
case "flyio":
21+
return FlyIOMachineJobClient{}, nil
22+
case "local-exec":
23+
return LocalExecJobClient{}, nil
2124
}
25+
return FlyIOMachineJobClient{}, nil
2226
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
main
1+
main
2+
/projects_refesh_main

ui/src/api/orchestrator_serverFunctions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const getProjectFn = createServerFn({method: 'GET'})
6262
.inputValidator((data : {projectId: string, organisationId: string, userId: string}) => data)
6363
.handler(async ({ data }) => {
6464
const project : any = await fetchProject(data.projectId, data.organisationId, data.userId)
65-
return project.result
65+
return project
6666
})
6767

6868

ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const Route = createFileRoute(
4747
loader: async ({ context, params: {projectid} }) => {
4848
const { user, organisationId } = context;
4949
const project = await getProjectFn({data: {projectId: projectid, organisationId, userId: user?.id || ''}})
50+
5051
return { project }
5152
}
5253
})

ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx

Lines changed: 63 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -91,54 +91,69 @@ function RouteComponent() {
9191
<CardDescription>List of projects detected accross all repositories. Each project represents a statefile and is loaded from digger.yml in the root of the repository.</CardDescription>
9292
</CardHeader>
9393
<CardContent>
94-
<Table>
95-
<TableHeader>
96-
<TableRow>
97-
<TableHead>Repository</TableHead>
98-
<TableHead>Name</TableHead>
99-
<TableHead>Directory</TableHead>
100-
<TableHead>Drift enabled</TableHead>
101-
<TableHead>Drift status</TableHead>
102-
<TableHead>Details</TableHead>
103-
</TableRow>
104-
</TableHeader>
105-
<TableBody>
106-
{projectList.map((project: Project) => {
107-
return (
108-
<TableRow key={project.id}>
109-
<TableCell>
110-
<a href={`https://github.com/${project.repo_full_name}`} target="_blank" rel="noopener noreferrer">
111-
{project.repo_full_name}
112-
</a>
113-
</TableCell>
114-
115-
<TableCell>{project.name}</TableCell>
116-
<TableCell>
117-
{project.directory}
118-
</TableCell>
119-
<TableCell>
120-
<input
121-
type="checkbox"
122-
checked={project.drift_enabled}
123-
onChange={() => handleDriftToggle(project)}
124-
className="h-4 w-4 rounded border-gray-300"
125-
/>
126-
</TableCell>
127-
<TableCell>
128-
{project.drift_status}
129-
</TableCell>
130-
<TableCell>
131-
<Button variant="ghost" asChild size="sm">
132-
<Link to="/dashboard/projects/$projectid" params={{ projectid: String(project.id) }}>
133-
View Details <ExternalLink className="ml-2 h-4 w-4" />
134-
</Link>
135-
</Button>
136-
</TableCell>
137-
</TableRow>
138-
)
139-
})}
140-
</TableBody>
141-
</Table>
94+
{projectList.length === 0 ? (
95+
<div className="text-center py-12">
96+
<h2 className="text-lg font-semibold mb-2">No Projects Found</h2>
97+
<p className="text-muted-foreground max-w-xl mx-auto mb-6">
98+
Projects represent entries loaded from your <code className="font-mono">digger.yml</code>.
99+
They will appear here when you connect repositories that contain a valid <code className="font-mono">digger.yml</code>.
100+
</p>
101+
<Button asChild>
102+
<Link to="/dashboard/onboarding" search={{ step: 'github' } as any}>
103+
Connect repositories
104+
</Link>
105+
</Button>
106+
</div>
107+
) : (
108+
<Table>
109+
<TableHeader>
110+
<TableRow>
111+
<TableHead>Repository</TableHead>
112+
<TableHead>Name</TableHead>
113+
<TableHead>Directory</TableHead>
114+
<TableHead>Drift enabled</TableHead>
115+
<TableHead>Drift status</TableHead>
116+
<TableHead>Details</TableHead>
117+
</TableRow>
118+
</TableHeader>
119+
<TableBody>
120+
{projectList.map((project: Project) => {
121+
return (
122+
<TableRow key={project.id}>
123+
<TableCell>
124+
<a href={`https://github.com/${project.repo_full_name}`} target="_blank" rel="noopener noreferrer">
125+
{project.repo_full_name}
126+
</a>
127+
</TableCell>
128+
129+
<TableCell>{project.name}</TableCell>
130+
<TableCell>
131+
{project.directory}
132+
</TableCell>
133+
<TableCell>
134+
<input
135+
type="checkbox"
136+
checked={project.drift_enabled}
137+
onChange={() => handleDriftToggle(project)}
138+
className="h-4 w-4 rounded border-gray-300"
139+
/>
140+
</TableCell>
141+
<TableCell>
142+
{project.drift_status}
143+
</TableCell>
144+
<TableCell>
145+
<Button variant="ghost" asChild size="sm">
146+
<Link to="/dashboard/projects/$projectid" params={{ projectid: String(project.id) }}>
147+
View Details <ExternalLink className="ml-2 h-4 w-4" />
148+
</Link>
149+
</Button>
150+
</TableCell>
151+
</TableRow>
152+
)
153+
})}
154+
</TableBody>
155+
</Table>
156+
)}
142157
</CardContent>
143158
</Card>
144159

ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import {
2020
DialogTrigger,
2121
} from "@/components/ui/dialog"
2222

23-
import { useState } from "react"
23+
import { useEffect, useState } from "react"
2424
import UnitCreateForm from "@/components/UnitCreateForm"
2525
import { listUnitsFn } from '@/api/statesman_serverFunctions'
2626
import { PageLoading } from '@/components/LoadingSkeleton'
27+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
28+
import { ChevronDown, X } from 'lucide-react'
2729

2830
export const Route = createFileRoute(
2931
'/_authenticated/_dashboard/dashboard/units/',
@@ -53,6 +55,57 @@ function formatBytes(bytes: number) {
5355
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
5456
}
5557

58+
function LoomBanner() {
59+
const [dismissed, setDismissed] = useState(false)
60+
const [open, setOpen] = useState(false)
61+
62+
useEffect(() => {
63+
if (typeof window === 'undefined') return
64+
window.localStorage.setItem('units_loom_open', String(open))
65+
}, [open])
66+
67+
const handleDismiss = () => {
68+
setDismissed(true)
69+
try {
70+
window.localStorage.setItem('units_loom_dismissed', 'true')
71+
} catch {}
72+
}
73+
74+
if (dismissed) return null
75+
76+
return (
77+
<div className="mb-4">
78+
<Collapsible open={open} onOpenChange={setOpen}>
79+
<div className="rounded-md border bg-muted/30">
80+
<div className="flex items-center justify-between px-4 py-3">
81+
<CollapsibleTrigger className="flex-1 flex items-center justify-between text-left">
82+
<span className="font-medium">Watch a quick walkthrough (2 min)</span>
83+
<ChevronDown className="h-4 w-4 transition-transform data-[state=open]:rotate-180" />
84+
</CollapsibleTrigger>
85+
<button
86+
className="ml-3 p-1 rounded hover:bg-muted"
87+
aria-label="Dismiss walkthrough"
88+
onClick={handleDismiss}
89+
>
90+
<X className="h-4 w-4" />
91+
</button>
92+
</div>
93+
<CollapsibleContent className="px-4 pb-4">
94+
<div className="relative pt-[56.25%]">
95+
<iframe
96+
src="https://www.loom.com/embed/0f303822db4147b1a0f89eeaa8df18ae"
97+
title="OpenTaco Units walkthrough"
98+
allow="autoplay; clipboard-write; encrypted-media; picture-in-picture"
99+
allowFullScreen
100+
className="absolute inset-0 h-full w-full rounded-md"
101+
/>
102+
</div>
103+
</CollapsibleContent>
104+
</div>
105+
</Collapsible>
106+
</div>
107+
)
108+
}
56109
function formatDate(value: any) {
57110
if (!value) return '—'
58111
const d = value instanceof Date ? value : new Date(value)
@@ -152,6 +205,9 @@ function RouteComponent() {
152205
</Link>
153206
</Button>
154207
</div>
208+
209+
{/* Loom walkthrough banner - collapsible and dismissible */}
210+
{ units.length === 0 && <LoomBanner /> }
155211

156212
<Card>
157213
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">

0 commit comments

Comments
 (0)