diff --git a/backend/service_clients/local_exec.go b/backend/service_clients/local_exec.go
new file mode 100644
index 000000000..b2fd08a65
--- /dev/null
+++ b/backend/service_clients/local_exec.go
@@ -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)
+ }
+}
diff --git a/backend/service_clients/router.go b/backend/service_clients/router.go
index c5fd15861..85011554f 100644
--- a/backend/service_clients/router.go
+++ b/backend/service_clients/router.go
@@ -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
}
diff --git a/background/projects-refresh-service/.gitignore b/background/projects-refresh-service/.gitignore
index 88d050b19..0973ffec2 100644
--- a/background/projects-refresh-service/.gitignore
+++ b/background/projects-refresh-service/.gitignore
@@ -1 +1,2 @@
-main
\ No newline at end of file
+main
+/projects_refesh_main
diff --git a/ui/src/api/orchestrator_serverFunctions.ts b/ui/src/api/orchestrator_serverFunctions.ts
index cf2e9da59..d093a37c9 100644
--- a/ui/src/api/orchestrator_serverFunctions.ts
+++ b/ui/src/api/orchestrator_serverFunctions.ts
@@ -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
})
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx
index 0dbeefcf5..153234854 100644
--- a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx
+++ b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx
@@ -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 }
}
})
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx
index 035b5019a..6dc77cead 100644
--- a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx
+++ b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx
@@ -91,54 +91,69 @@ function RouteComponent() {
List of projects detected accross all repositories. Each project represents a statefile and is loaded from digger.yml in the root of the repository.
-
+ {projectList.length === 0 ? (
+
+
No Projects Found
+
+ Projects represent entries loaded from your digger.yml.
+ They will appear here when you connect repositories that contain a valid digger.yml.
+
+
+
+ ) : (
+
+ )}
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx
index ecd9267e0..ff2d47915 100644
--- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx
+++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx
@@ -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/',
@@ -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 (
+
+
+
+
+
+ Watch a quick walkthrough (2 min)
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
function formatDate(value: any) {
if (!value) return '—'
const d = value instanceof Date ? value : new Date(value)
@@ -152,6 +205,9 @@ function RouteComponent() {
+
+ {/* Loom walkthrough banner - collapsible and dismissible */}
+ { units.length === 0 && }