Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions backend/service_clients/local_exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package service_clients

import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
)

type LocalExecJobClient struct {}

// TriggerProjectsRefreshLocal starts a local binary with the required environment.
// Binary path is taken from PROJECTS_REFRESH_BIN (fallback: ./projects-refresh-service).
// It does NOT wait for completion; it returns as soon as the process starts successfully.
func (f LocalExecJobClient) TriggerProjectsRefreshService(
cloneUrl, branch, githubToken, repoFullName, orgId string,
) (*BackgroundJobTriggerResponse, error) {

slog.Debug("starting local projects-refresh-service job")

// Resolve binary path from env or default.
bin := os.Getenv("PROJECTS_REFRESH_BIN")
if bin == "" {
bin = "../../background/projects-refresh-service/projects_refesh_main"
}

// Optional: working directory (set via env if you want), otherwise current dir.
workingDir := os.Getenv("PROJECTS_REFRESH_WORKDIR")
if workingDir == "" {
wd, _ := os.Getwd()
workingDir = wd
}

// Build environment for the child process.
// Keep existing env and append required vars.
env := append(os.Environ(),
"DIGGER_GITHUB_REPO_CLONE_URL="+cloneUrl,
"DIGGER_GITHUB_REPO_CLONE_BRANCH="+branch,
"DIGGER_GITHUB_TOKEN="+githubToken,
"DIGGER_REPO_FULL_NAME="+repoFullName,
"DIGGER_ORG_ID="+orgId,
"DATABASE_URL="+os.Getenv("DATABASE_URL"),
)

// Optional: add any tuning flags you previously used in the container world.
// env = append(env, "GODEBUG=off", "GOFIPS140=off")

// If your binary needs args, add them here. Empty for now.
cmd := exec.Command(bin)
cmd.Dir = workingDir
cmd.Env = env

// Pipe stdout/stderr to slog for observability.
stdout, err := cmd.StdoutPipe()
if err != nil {
slog.Error("allocating stdout pipe failed", "error", err)
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
slog.Error("allocating stderr pipe failed", "error", err)
return nil, err
}

// Start process.
if err := cmd.Start(); err != nil {
slog.Error("failed to start local job", "binary", bin, "dir", workingDir, "error", err)
return nil, err
}

// Stream logs in background goroutines tied to a short-lived context so we don't leak.
ctx, cancel := context.WithCancel(context.Background())
go pipeToSlog(ctx, stdout, slog.LevelInfo, "projects-refresh")
go pipeToSlog(ctx, stderr, slog.LevelError, "projects-refresh")

// Optionally, you can watch for process exit in a goroutine if you want to log completion.
go func() {
defer cancel()
waitErr := cmd.Wait()
if waitErr != nil {
slog.Error("local job exited with error", "pid", cmd.Process.Pid, "error", waitErr)
return
}
slog.Info("local job completed", "pid", cmd.Process.Pid)
}()

slog.Debug("triggered local projects refresh", "pid", cmd.Process.Pid, "binary", bin, "workdir", workingDir)

return &BackgroundJobTriggerResponse{ID: fmt.Sprintf("%d", cmd.Process.Pid)}, nil
}

// pipeToSlog streams a reader line-by-line into slog at the given level.
func pipeToSlog(ctx context.Context, r io.Reader, level slog.Level, comp string) {
br := bufio.NewScanner(r)
// Increase the Scanner buffer in case the tool emits long lines.
buf := make([]byte, 0, 64*1024)
br.Buffer(buf, 10*1024*1024)

for br.Scan() {
select {
case <-ctx.Done():
return
default:
slog.Log(context.Background(), level, br.Text(), "component", comp)
}
}
if err := br.Err(); err != nil {
slog.Error("log stream error", "component", comp, "error", err)
}
}
26 changes: 15 additions & 11 deletions backend/service_clients/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ import (

func GetBackgroundJobsClient() (BackgroundJobsClient, error) {
clientType := os.Getenv("BACKGROUND_JOBS_CLIENT_TYPE")
if clientType == "k8s" {
clientSet, err := newInClusterClient()
if err != nil {
return nil, fmt.Errorf("error creating k8s client: %v", err)
}
return K8sJobClient{
clientset: clientSet,
namespace: "opentaco",
}, nil
} else {
return FlyIOMachineJobClient{}, nil
switch clientType {
case "k8s":
clientSet, err := newInClusterClient()
if err != nil {
return nil, fmt.Errorf("error creating k8s client: %v", err)
}
return K8sJobClient{
clientset: clientSet,
namespace: "opentaco",
}, nil
case "flyio":
return FlyIOMachineJobClient{}, nil
case "local-exec":
return LocalExecJobClient{}, nil
}
return FlyIOMachineJobClient{}, nil
}
3 changes: 2 additions & 1 deletion background/projects-refresh-service/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
main
main
/projects_refesh_main
2 changes: 1 addition & 1 deletion ui/src/api/orchestrator_serverFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const getProjectFn = createServerFn({method: 'GET'})
.inputValidator((data : {projectId: string, organisationId: string, userId: string}) => data)
.handler(async ({ data }) => {
const project : any = await fetchProject(data.projectId, data.organisationId, data.userId)
return project.result
return project
})


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const Route = createFileRoute(
loader: async ({ context, params: {projectid} }) => {
const { user, organisationId } = context;
const project = await getProjectFn({data: {projectId: projectid, organisationId, userId: user?.id || ''}})

return { project }
}
})
Expand Down
111 changes: 63 additions & 48 deletions ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,54 +91,69 @@ function RouteComponent() {
<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>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Name</TableHead>
<TableHead>Directory</TableHead>
<TableHead>Drift enabled</TableHead>
<TableHead>Drift status</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectList.map((project: Project) => {
return (
<TableRow key={project.id}>
<TableCell>
<a href={`https://github.com/${project.repo_full_name}`} target="_blank" rel="noopener noreferrer">
{project.repo_full_name}
</a>
</TableCell>

<TableCell>{project.name}</TableCell>
<TableCell>
{project.directory}
</TableCell>
<TableCell>
<input
type="checkbox"
checked={project.drift_enabled}
onChange={() => handleDriftToggle(project)}
className="h-4 w-4 rounded border-gray-300"
/>
</TableCell>
<TableCell>
{project.drift_status}
</TableCell>
<TableCell>
<Button variant="ghost" asChild size="sm">
<Link to="/dashboard/projects/$projectid" params={{ projectid: String(project.id) }}>
View Details <ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{projectList.length === 0 ? (
<div className="text-center py-12">
<h2 className="text-lg font-semibold mb-2">No Projects Found</h2>
<p className="text-muted-foreground max-w-xl mx-auto mb-6">
Projects represent entries loaded from your <code className="font-mono">digger.yml</code>.
They will appear here when you connect repositories that contain a valid <code className="font-mono">digger.yml</code>.
</p>
<Button asChild>
<Link to="/dashboard/onboarding" search={{ step: 'github' } as any}>
Connect repositories
</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Name</TableHead>
<TableHead>Directory</TableHead>
<TableHead>Drift enabled</TableHead>
<TableHead>Drift status</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectList.map((project: Project) => {
return (
<TableRow key={project.id}>
<TableCell>
<a href={`https://github.com/${project.repo_full_name}`} target="_blank" rel="noopener noreferrer">
{project.repo_full_name}
</a>
</TableCell>

<TableCell>{project.name}</TableCell>
<TableCell>
{project.directory}
</TableCell>
<TableCell>
<input
type="checkbox"
checked={project.drift_enabled}
onChange={() => handleDriftToggle(project)}
className="h-4 w-4 rounded border-gray-300"
/>
</TableCell>
<TableCell>
{project.drift_status}
</TableCell>
<TableCell>
<Button variant="ghost" asChild size="sm">
<Link to="/dashboard/projects/$projectid" params={{ projectid: String(project.id) }}>
View Details <ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"

import { useState } from "react"
import { useEffect, useState } from "react"
import UnitCreateForm from "@/components/UnitCreateForm"
import { listUnitsFn } from '@/api/statesman_serverFunctions'
import { PageLoading } from '@/components/LoadingSkeleton'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, X } from 'lucide-react'

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

function LoomBanner() {
const [dismissed, setDismissed] = useState(false)
const [open, setOpen] = useState(false)

useEffect(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem('units_loom_open', String(open))
}, [open])

const handleDismiss = () => {
setDismissed(true)
try {
window.localStorage.setItem('units_loom_dismissed', 'true')
} catch {}
}

if (dismissed) return null

return (
<div className="mb-4">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-md border bg-muted/30">
<div className="flex items-center justify-between px-4 py-3">
<CollapsibleTrigger className="flex-1 flex items-center justify-between text-left">
<span className="font-medium">Watch a quick walkthrough (2 min)</span>
<ChevronDown className="h-4 w-4 transition-transform data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<button
className="ml-3 p-1 rounded hover:bg-muted"
aria-label="Dismiss walkthrough"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</button>
</div>
<CollapsibleContent className="px-4 pb-4">
<div className="relative pt-[56.25%]">
<iframe
src="https://www.loom.com/embed/0f303822db4147b1a0f89eeaa8df18ae"
title="OpenTaco Units walkthrough"
allow="autoplay; clipboard-write; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 h-full w-full rounded-md"
/>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
)
}
function formatDate(value: any) {
if (!value) return '—'
const d = value instanceof Date ? value : new Date(value)
Expand Down Expand Up @@ -152,6 +205,9 @@ function RouteComponent() {
</Link>
</Button>
</div>

{/* Loom walkthrough banner - collapsible and dismissible */}
{ units.length === 0 && <LoomBanner /> }

<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
Expand Down
Loading