mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4307034ec | ||
|
|
985d22798c | ||
|
|
26f495ac8e |
No files matched your search
+15
-7
@@ -507,15 +507,19 @@ and wired refs, one click or one CLI call.
|
|||||||
deployment history, env-doctor issues surfaced on the service view.
|
deployment history, env-doctor issues surfaced on the service view.
|
||||||
- [x] **env var edit + domain management + replica scaling** (PR #61 —
|
- [x] **env var edit + domain management + replica scaling** (PR #61 —
|
||||||
mobile v1.5: variables editor, domains screen, scale + sleep
|
mobile v1.5: variables editor, domains screen, scale + sleep
|
||||||
settings, notifications tab). **cron execution history** still open.
|
settings, notifications tab, cron jobs screen with run-now and
|
||||||
|
execution history).
|
||||||
- [x] **Push notifications** — per-user `notification_channels`
|
- [x] **Push notifications** — per-user `notification_channels`
|
||||||
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
|
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
|
||||||
to enabled channels; CRUD + live test-ping via web settings, CLI
|
to enabled channels; CRUD + live test-ping via web settings, CLI
|
||||||
`notifications channels`, and MCP. FCM/APNs remain optional.
|
`notifications channels`, and MCP. FCM/APNs remain optional.
|
||||||
- [ ] **Offline resilience** — cached last-known state + unreachable
|
- [x] **Offline resilience** — AsyncStorage-persisted query cache
|
||||||
banner.
|
(7d, high-churn keys excluded) + banner when the instance is
|
||||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
unreachable (`/health` ping + expo-network).
|
||||||
the instance is unreachable (cached last-known state, clear banner).
|
- [x] **Status/offline resilience** — web persists the query cache to
|
||||||
|
localStorage (24h) and PlatformShell shows a "last-known data" banner
|
||||||
|
on offline/unreachable; CLI exits `ExitUnavailable` with a clean
|
||||||
|
"cannot reach" message (no stack traces).
|
||||||
|
|
||||||
## 10. Carried-over debt (ROADMAP.md items still open)
|
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||||
|
|
||||||
@@ -533,8 +537,12 @@ and wired refs, one click or one CLI call.
|
|||||||
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||||
webhook table tests, Playwright e2e golden path.
|
webhook table tests, Playwright e2e golden path.
|
||||||
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
||||||
Phase G makes this self-serve) → Tunnel → done.
|
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
|
||||||
|
manifest flow (callback returns to the wizard via a session marker) →
|
||||||
|
optional Cloudflare tunnel token → done. Shell redirects signed-in
|
||||||
|
users to `/setup` while `needs_setup`; installs with existing projects
|
||||||
|
auto-complete so nobody gets nagged retroactively.
|
||||||
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||||
|
|
||||||
## 11. Deliberately not imported from dflow
|
## 11. Deliberately not imported from dflow
|
||||||
|
|||||||
@@ -803,9 +803,15 @@ func splitImageReference(image, fallbackTag string) (string, string) {
|
|||||||
return "", fallbackTag
|
return "", fallbackTag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Digest-pinned refs (name[:tag]@sha256:…) carry no usable tag — the
|
||||||
|
// colon lives inside the digest and must not be split.
|
||||||
|
if strings.Contains(image, "@") {
|
||||||
|
return image, fallbackTag
|
||||||
|
}
|
||||||
|
|
||||||
lastSlash := strings.LastIndex(image, "/")
|
lastSlash := strings.LastIndex(image, "/")
|
||||||
lastColon := strings.LastIndex(image, ":")
|
lastColon := strings.LastIndex(image, ":")
|
||||||
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
|
if lastColon > lastSlash {
|
||||||
return image[:lastColon], image[lastColon+1:]
|
return image[:lastColon], image[lastColon+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitImageReference(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
fallback string
|
||||||
|
wantImage string
|
||||||
|
wantTag string
|
||||||
|
}{
|
||||||
|
{"empty uses fallback tag", "", "latest", "", "latest"},
|
||||||
|
{"bare image", "nginx", "latest", "nginx", "latest"},
|
||||||
|
{"tagged image", "nginx:1.27", "latest", "nginx", "1.27"},
|
||||||
|
{"registry with port untagged", "registry.local:5000/app", "latest", "registry.local:5000/app", "latest"},
|
||||||
|
{"registry with port tagged", "registry.local:5000/app:v2", "latest", "registry.local:5000/app", "v2"},
|
||||||
|
{"digest stays whole", "nginx@sha256:abc123", "latest", "nginx@sha256:abc123", "latest"},
|
||||||
|
{"tag and digest", "nginx:1.27@sha256:abc", "latest", "nginx:1.27@sha256:abc", "latest"},
|
||||||
|
{"namespaced tagged", "ghcr.io/owner/repo:dev", "latest", "ghcr.io/owner/repo", "dev"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
img, tag := splitImageReference(tc.image, tc.fallback)
|
||||||
|
if img != tc.wantImage || tag != tc.wantTag {
|
||||||
|
t.Fatalf("splitImageReference(%q, %q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.image, tc.fallback, img, tag, tc.wantImage, tc.wantTag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapEngineStatusToDBStatus(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"running": "deployed",
|
||||||
|
"building": "building",
|
||||||
|
"failed": "failed",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
"queued": "queued",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := mapEngineStatusToDBStatus(in); got != want {
|
||||||
|
t.Fatalf("mapEngineStatusToDBStatus(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -286,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
public.GET("/maintenance", handleMaintenancePage)
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
public.GET("/branding", handleGetBranding)
|
public.GET("/branding", handleGetBranding)
|
||||||
|
public.GET("/setup/status", handleSetupStatus)
|
||||||
|
|
||||||
// Invite accept flow — validates the link, then registers
|
// Invite accept flow — validates the link, then registers
|
||||||
// even while public signup is closed.
|
// even while public signup is closed.
|
||||||
@@ -355,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
{
|
{
|
||||||
authed.GET("/user/profile", handleGetProfile)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
authed.PUT("/user/profile", handleUpdateProfile)
|
authed.PUT("/user/profile", handleUpdateProfile)
|
||||||
|
authed.POST("/setup/complete", handleSetupComplete)
|
||||||
|
|
||||||
// Personal access tokens for CLI/MCP/agent auth.
|
// Personal access tokens for CLI/MCP/agent auth.
|
||||||
authed.GET("/user/tokens", handleListUserTokens)
|
authed.GET("/user/tokens", handleListUserTokens)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// First-run setup wizard state. GET /setup/status is public so the web
|
||||||
|
// shell can redirect fresh installs before a session exists; the flag
|
||||||
|
// lives in app_settings so it survives restarts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const settingSetupCompleted = "setup_completed"
|
||||||
|
|
||||||
|
// setupComplete resolves the flag. Installs that predate the wizard
|
||||||
|
// (users + projects already exist) auto-complete — nobody who has been
|
||||||
|
// running Containr for months should get a welcome screen.
|
||||||
|
func setupComplete(db *database.DB, hasUsers bool) bool {
|
||||||
|
if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !hasUsers {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var projects int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/setup/status — public: fresh installs hit it before any
|
||||||
|
// session exists.
|
||||||
|
func handleSetupStatus(c *gin.Context) {
|
||||||
|
db, ok := c.MustGet("db").(*database.DB)
|
||||||
|
if !ok {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := countLocalUsers(db)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasUsers := count > 0
|
||||||
|
completed := setupComplete(db, hasUsers)
|
||||||
|
tunnelToken, _ := resolvedTunnelToken(db)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"has_users": hasUsers,
|
||||||
|
"setup_completed": completed,
|
||||||
|
"needs_setup": !completed,
|
||||||
|
"has_github_app": githubAppConfigured(db),
|
||||||
|
"has_tunnel": tunnelToken != "",
|
||||||
|
"signup_enabled": signupEnabled(db),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/setup/complete — marks the wizard done. Open to any
|
||||||
|
// authenticated user: the first account is the owner, and the flag only
|
||||||
|
// suppresses a banner.
|
||||||
|
func handleSetupComplete(c *gin.Context) {
|
||||||
|
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := c.MustGet("db").(*database.DB)
|
||||||
|
if !ok {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
|
||||||
|
}
|
||||||
Generated
+54
-8
@@ -31,7 +31,9 @@
|
|||||||
"@tabler/icons-react": "^3.37.1",
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
@@ -3441,22 +3443,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
||||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
"integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/query-persist-client-core": {
|
||||||
"version": "5.90.21",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/query-sync-storage-persister": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1",
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3466,6 +3495,23 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-persist-client": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -42,7 +42,9 @@
|
|||||||
"@tabler/icons-react": "^3.37.1",
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@/features/secondary/pages';
|
} from '@/features/secondary/pages';
|
||||||
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
||||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||||
|
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
@@ -138,6 +139,7 @@ export default function App() {
|
|||||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||||
|
<Route path="/setup" element={<SetupWizardPage />} />
|
||||||
|
|
||||||
{/* Public browsing — approved projects, templates, docs. */}
|
{/* Public browsing — approved projects, templates, docs. */}
|
||||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
@@ -30,10 +30,13 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
WifiOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getUpgradeStatus,
|
getUpgradeStatus,
|
||||||
|
pingServer,
|
||||||
|
getSetupStatus,
|
||||||
listNotifications,
|
listNotifications,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
@@ -105,6 +108,42 @@ function getInitialSidebar(): boolean {
|
|||||||
return readStorage('containr.sidebar.expanded') !== '0';
|
return readStorage('containr.sidebar.expanded') !== '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectivityBanner pings /health every 15s and listens for browser
|
||||||
|
// online/offline events — when the instance is unreachable it shows a
|
||||||
|
// slim bar so the persisted last-known data doesn't masquerade as live.
|
||||||
|
function ConnectivityBanner() {
|
||||||
|
const [offline, setOffline] = useState(() => !navigator.onLine);
|
||||||
|
useEffect(() => {
|
||||||
|
const up = () => setOffline(false);
|
||||||
|
const down = () => setOffline(true);
|
||||||
|
window.addEventListener('online', up);
|
||||||
|
window.addEventListener('offline', down);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', up);
|
||||||
|
window.removeEventListener('offline', down);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const ping = useQuery({
|
||||||
|
queryKey: ['connectivity'],
|
||||||
|
queryFn: pingServer,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
retry: 0,
|
||||||
|
networkMode: 'always',
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
if (!offline && !ping.isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 border-b border-[var(--warning,var(--border-subtle))] bg-[var(--surface-muted)] px-4 py-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<WifiOff size={13} className="text-[var(--warning,#f59e0b)]" />
|
||||||
|
{offline
|
||||||
|
? 'No network connection — showing last-known data.'
|
||||||
|
: 'Cannot reach this Containr instance — showing last-known data. Retrying…'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PlatformShell() {
|
export function PlatformShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -147,6 +186,13 @@ export function PlatformShell() {
|
|||||||
enabled: !isDemoMode && signedIn,
|
enabled: !isDemoMode && signedIn,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
// Fresh installs get routed through the setup wizard until the flag lands.
|
||||||
|
const setupQuery = useQuery({
|
||||||
|
queryKey: ['setup-status'],
|
||||||
|
queryFn: getSetupStatus,
|
||||||
|
enabled: !isDemoMode && signedIn,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: (id: string) => markNotificationRead(id),
|
mutationFn: (id: string) => markNotificationRead(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||||
@@ -209,6 +255,10 @@ export function PlatformShell() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell min-h-screen">
|
<div className="app-shell min-h-screen">
|
||||||
<div className="ambient-glow" />
|
<div className="ambient-glow" />
|
||||||
@@ -557,6 +607,7 @@ export function PlatformShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<ConnectivityBanner />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1253,7 +1253,11 @@ export function GitHubAppCallbackPage() {
|
|||||||
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
||||||
navigate('/settings', { replace: true });
|
// The setup wizard stashes a return marker before submitting the
|
||||||
|
// manifest form — resume there instead of the settings page.
|
||||||
|
const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
|
||||||
|
sessionStorage.removeItem('containr.setup_return');
|
||||||
|
navigate(inSetup ? '/setup' : '/settings', { replace: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const firedRef = useRef(false);
|
const firedRef = useRef(false);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { SetupWizardPage } from './SetupWizardPage';
|
||||||
|
import type { SetupStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
const mockStatus = vi.fn<() => Promise<SetupStatus>>();
|
||||||
|
const mockSession = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import('@/lib/api-client')>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
getSetupStatus: () => mockStatus(),
|
||||||
|
completeSetup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/use-auth-session', () => ({
|
||||||
|
useAuthSession: () => mockSession(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWizard() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/setup']}>
|
||||||
|
<SetupWizardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: SetupStatus = {
|
||||||
|
has_users: false,
|
||||||
|
setup_completed: false,
|
||||||
|
needs_setup: true,
|
||||||
|
has_github_app: false,
|
||||||
|
has_tunnel: false,
|
||||||
|
signup_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SetupWizardPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSession.mockReturnValue({ data: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts for the admin account on a fresh install', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/create admin account/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers sign-in when users exist but nobody is signed in', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the git provider step for a signed-in admin without GitHub App', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/set up github app/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the tunnel step when git is already wired', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true });
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: /public access/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands on the finish step when everything is configured', async () => {
|
||||||
|
mockStatus.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
has_users: true,
|
||||||
|
has_github_app: true,
|
||||||
|
has_tunnel: true,
|
||||||
|
});
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/enter containr/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import {
|
||||||
|
completeSetup,
|
||||||
|
createGitHubAppManifest,
|
||||||
|
getSetupStatus,
|
||||||
|
updatePlatformSettings,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
|
||||||
|
// Marker the GitHub App callback reads so the wizard flow resumes here
|
||||||
|
// instead of the settings page.
|
||||||
|
const SETUP_RETURN_KEY = 'containr.setup_return';
|
||||||
|
|
||||||
|
type Step = 'account' | 'github' | 'tunnel' | 'done';
|
||||||
|
|
||||||
|
export function SetupWizardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const sessionQuery = useAuthSession();
|
||||||
|
const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus });
|
||||||
|
const [override, setOverride] = useState<Step | null>(null);
|
||||||
|
|
||||||
|
const status = statusQuery.data;
|
||||||
|
const signedIn = Boolean(sessionQuery.data);
|
||||||
|
|
||||||
|
const step: Step = useMemo(() => {
|
||||||
|
if (override) return override;
|
||||||
|
if (!status) return 'account';
|
||||||
|
if (!status.has_users || !signedIn) return 'account';
|
||||||
|
if (!status.has_github_app) return 'github';
|
||||||
|
if (!status.has_tunnel) return 'tunnel';
|
||||||
|
return 'done';
|
||||||
|
}, [override, status, signedIn]);
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: completeSetup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||||
|
navigate('/projects', { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||||
|
<Loader2 size={22} className="animate-spin text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard is optional once the instance is already configured.
|
||||||
|
if (status && !status.needs_setup && !override) {
|
||||||
|
return <Navigate to="/projects" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: { key: Step; label: string }[] = [
|
||||||
|
{ key: 'account', label: 'Admin account' },
|
||||||
|
{ key: 'github', label: 'Git provider' },
|
||||||
|
{ key: 'tunnel', label: 'Public access' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
];
|
||||||
|
const activeIdx = steps.findIndex((s) => s.key === step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--bg-primary)] flex flex-col items-center px-5 py-16">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-8">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold border ${
|
||||||
|
i < activeIdx
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-[var(--accent-on)]'
|
||||||
|
: i === activeIdx
|
||||||
|
? 'border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < activeIdx ? <Check size={12} /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${i === activeIdx ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 ? <div className="w-6 h-px bg-[var(--border-subtle)]" /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'account' ? (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
First boot detected. Create the admin account — registration closes to the public
|
||||||
|
right after, so this account owns the instance.
|
||||||
|
</p>
|
||||||
|
{status?.has_users && !signedIn ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
An account already exists — sign in to continue setup.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/auth/sign-in?redirect=/setup"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Sign in <ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : !status?.has_users ? (
|
||||||
|
<Link
|
||||||
|
to="/auth/sign-up?redirect=/setup"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Create admin account <ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'github' ? <GitHubStep onNext={() => setOverride('tunnel')} /> : null}
|
||||||
|
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
|
||||||
|
|
||||||
|
{step === 'done' ? (
|
||||||
|
<section className="panel p-6 text-center">
|
||||||
|
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
|
||||||
|
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
Deploy your first service from a git repo, an image, or a template — the dashboard is
|
||||||
|
ready.
|
||||||
|
</p>
|
||||||
|
{completeMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{completeMutation.error instanceof Error
|
||||||
|
? completeMutation.error.message
|
||||||
|
: 'Failed to save setup state'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
disabled={completeMutation.isPending}
|
||||||
|
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
)}
|
||||||
|
Enter Containr
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const provisionMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { url, manifest } = await createGitHubAppManifest({
|
||||||
|
base_url: window.location.origin,
|
||||||
|
});
|
||||||
|
sessionStorage.setItem(SETUP_RETURN_KEY, '1');
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = url;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'manifest';
|
||||||
|
input.value = manifest;
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Github size={20} className="text-[var(--text-primary)]" />
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Provision the instance GitHub App — repo push events then trigger deployments and preview
|
||||||
|
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
|
||||||
|
</p>
|
||||||
|
{provisionMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{provisionMutation.error instanceof Error
|
||||||
|
? provisionMutation.error.message
|
||||||
|
: 'Could not start GitHub App provisioning'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => provisionMutation.mutate()}
|
||||||
|
disabled={provisionMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
|
Set up GitHub App
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TunnelStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||||
|
onNext();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
|
||||||
|
Containr stays reachable on this network — you can add it later in Settings.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="eyJhIjo… tunnel token"
|
||||||
|
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
|
||||||
|
/>
|
||||||
|
{saveMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
|
{token.trim() ? 'Save tunnel token' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5633,6 +5633,90 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/setup/status": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* First-run setup status
|
||||||
|
* @description Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Setup state */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
has_users?: boolean;
|
||||||
|
setup_completed?: boolean;
|
||||||
|
needs_setup?: boolean;
|
||||||
|
/** @description GitHub App credentials configured (manifest flow or env) */
|
||||||
|
has_github_app?: boolean;
|
||||||
|
/** @description Cloudflare tunnel token configured */
|
||||||
|
has_tunnel?: boolean;
|
||||||
|
signup_enabled?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/setup/complete": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Mark the setup wizard complete */
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Setup completed */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MessageResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/settings": {
|
"/settings": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -490,6 +490,32 @@ export function getApiBaseUrl(): string {
|
|||||||
return API_BASE;
|
return API_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pingServer hits the public /health route — any HTTP response proves
|
||||||
|
// reachability, and no auth means no 401 noise in the connectivity probe.
|
||||||
|
export async function pingServer(): Promise<void> {
|
||||||
|
const res = await fetch(`${API_ROOT}/health`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetupStatus = {
|
||||||
|
has_users: boolean;
|
||||||
|
setup_completed: boolean;
|
||||||
|
needs_setup: boolean;
|
||||||
|
has_github_app: boolean;
|
||||||
|
has_tunnel: boolean;
|
||||||
|
signup_enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||||
|
return requestJson<SetupStatus>('/setup/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeSetup(): Promise<void> {
|
||||||
|
await requestJson('/setup/complete', { method: 'POST', body: JSON.stringify({}) });
|
||||||
|
}
|
||||||
|
|
||||||
export function getAgentPublicBaseUrl(): string {
|
export function getAgentPublicBaseUrl(): string {
|
||||||
return `${API_ROOT}/api/agents`;
|
return `${API_ROOT}/api/agents`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
|
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './app/App';
|
import App from './app/App';
|
||||||
import { ToastProvider } from './shared/components';
|
import { ToastProvider } from './shared/components';
|
||||||
@@ -12,18 +14,49 @@ const queryClient = new QueryClient({
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
// Serve the persisted snapshot while offline instead of erroring.
|
||||||
|
networkMode: 'offlineFirst',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// High-churn or bulky keys never hit localStorage — a stale log tail is
|
||||||
|
// worse than none.
|
||||||
|
const NEVER_PERSIST = new Set([
|
||||||
|
'shell-notifications',
|
||||||
|
'notifications',
|
||||||
|
'service-logs',
|
||||||
|
'logs',
|
||||||
|
'activity',
|
||||||
|
'audit-logs',
|
||||||
|
'audit',
|
||||||
|
'metrics',
|
||||||
|
'connectivity',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const persister = createSyncStoragePersister({
|
||||||
|
storage: window.localStorage,
|
||||||
|
key: 'containr-query-cache-v1',
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (q) =>
|
||||||
|
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
Generated
+91
@@ -9,11 +9,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.104.1",
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"expo": "~57.0.27",
|
"expo": "~57.0.27",
|
||||||
"expo-constants": "~57.0.21",
|
"expo-constants": "~57.0.21",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"expo-linking": "~57.0.12",
|
"expo-linking": "~57.0.12",
|
||||||
|
"expo-network": "~57.0.2",
|
||||||
"expo-router": "~57.0.25",
|
"expo-router": "~57.0.25",
|
||||||
"expo-secure-store": "~57.0.4",
|
"expo-secure-store": "~57.0.4",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
@@ -2165,6 +2169,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"merge-options": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native-masked-view/masked-view": {
|
"node_modules/@react-native-masked-view/masked-view": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
|
||||||
@@ -2469,6 +2485,20 @@
|
|||||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-async-storage-persister": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-wSs/HrangoSMVW3zLifawRT9lA+fbiilPQmaY+YhxBOM/VS9oqx+MQvROHpW+hKwDpdvFmhyBjWiyDhTHL1YXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1",
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.104.1",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
||||||
@@ -2479,6 +2509,19 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-persist-client-core": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.104.1",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
|
||||||
@@ -2495,6 +2538,23 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-persist-client": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.4",
|
"version": "0.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||||
@@ -5416,6 +5476,16 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-network": {
|
||||||
|
"version": "57.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz",
|
||||||
|
"integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "57.0.25",
|
"version": "57.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz",
|
||||||
@@ -7084,6 +7154,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -7968,6 +8047,18 @@
|
|||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-options": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-obj": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.104.1",
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"expo": "~57.0.27",
|
"expo": "~57.0.27",
|
||||||
"expo-constants": "~57.0.21",
|
"expo-constants": "~57.0.21",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"expo-linking": "~57.0.12",
|
"expo-linking": "~57.0.12",
|
||||||
|
"expo-network": "~57.0.2",
|
||||||
"expo-router": "~57.0.25",
|
"expo-router": "~57.0.25",
|
||||||
"expo-secure-store": "~57.0.4",
|
"expo-secure-store": "~57.0.4",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ export interface NotificationChannel {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CronJob {
|
||||||
|
id: string;
|
||||||
|
service_id?: string;
|
||||||
|
name: string;
|
||||||
|
schedule: string;
|
||||||
|
command: string;
|
||||||
|
timezone?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
last_run_at?: string;
|
||||||
|
next_run_at?: string;
|
||||||
|
last_status?: string;
|
||||||
|
last_output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronExecution {
|
||||||
|
id: string;
|
||||||
|
cron_job_id: string;
|
||||||
|
started_at?: string;
|
||||||
|
finished_at?: string;
|
||||||
|
status?: string;
|
||||||
|
output?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Masked value the API returns for secret variables; echoing it back on
|
// Masked value the API returns for secret variables; echoing it back on
|
||||||
// update preserves the stored ciphertext.
|
// update preserves the stored ciphertext.
|
||||||
export const MASKED_SECRET = '********';
|
export const MASKED_SECRET = '********';
|
||||||
@@ -273,6 +297,24 @@ export class Api {
|
|||||||
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {});
|
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cronJobs(serviceId: string) {
|
||||||
|
return this.req<{ cron_jobs: CronJob[] }>(
|
||||||
|
'GET',
|
||||||
|
`/cron-jobs?service_id=${serviceId}`,
|
||||||
|
).then((r) => r.cron_jobs ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
cronExecutions(jobId: string) {
|
||||||
|
return this.req<{ executions: CronExecution[] }>(
|
||||||
|
'GET',
|
||||||
|
`/cron-jobs/${jobId}/executions`,
|
||||||
|
).then((r) => r.executions ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerCronJob(jobId: string) {
|
||||||
|
return this.req<unknown>('POST', `/cron-jobs/${jobId}/trigger`, {});
|
||||||
|
}
|
||||||
|
|
||||||
serviceDeployments(id: string) {
|
serviceDeployments(id: string) {
|
||||||
return this.req<{ deployments: Deployment[] }>(
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
'GET',
|
'GET',
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import * as Network from 'expo-network';
|
||||||
import { useApi } from '../../session';
|
import { useApi } from '../../session';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
|
// OfflineBanner pings /health every 15s; expo-network gives a fast local
|
||||||
|
// signal, the ping catches "device online but instance down".
|
||||||
|
function OfflineBanner() {
|
||||||
|
const api = useApi();
|
||||||
|
const net = useQuery({
|
||||||
|
queryKey: ['connectivity', 'net'],
|
||||||
|
queryFn: async () => (await Network.getNetworkStateAsync()).isConnected !== false,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
const ping = useQuery({
|
||||||
|
queryKey: ['connectivity', 'ping'],
|
||||||
|
queryFn: () => api.ping(),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
retry: 0,
|
||||||
|
networkMode: 'always',
|
||||||
|
});
|
||||||
|
if (net.data !== false && ping.data !== false && !ping.isError) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<Ionicons name="cloud-offline-outline" size={13} color={colors.warn} />
|
||||||
|
<Text style={styles.bannerText}>
|
||||||
|
{net.data === false ? 'No connection' : 'Instance unreachable'} — showing last-known data
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const notifs = useQuery({
|
const notifs = useQuery({
|
||||||
@@ -15,7 +44,9 @@ export default function TabsLayout() {
|
|||||||
const unread = notifs.data?.unread ?? 0;
|
const unread = notifs.data?.unread ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<View style={{ flex: 1 }}>
|
||||||
|
<OfflineBanner />
|
||||||
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: colors.bg },
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
headerTintColor: colors.text,
|
headerTintColor: colors.text,
|
||||||
@@ -70,6 +101,21 @@ export default function TabsLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.warn,
|
||||||
|
},
|
||||||
|
bannerText: { color: colors.textDim, fontSize: 11 },
|
||||||
|
});
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { SessionProvider, useSession } from '../session';
|
import { SessionProvider, useSession } from '../session';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { staleTime: 15_000, retry: 1 },
|
queries: { staleTime: 15_000, retry: 1, networkMode: 'offlineFirst' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// High-churn keys never hit disk — a stale notification count or log
|
||||||
|
// tail is worse than none.
|
||||||
|
const NEVER_PERSIST = new Set(['notifications', 'notification-channels', 'logs', 'connectivity']);
|
||||||
|
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'containr-query-cache-v1',
|
||||||
|
});
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { ready, api } = useSession();
|
const { ready, api } = useSession();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
@@ -39,6 +51,7 @@ function AuthGate() {
|
|||||||
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
||||||
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
|
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
|
||||||
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
|
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
|
||||||
|
<Stack.Screen name="service/cron" options={{ title: 'Cron jobs' }} />
|
||||||
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
|
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
@@ -48,11 +61,21 @@ function AuthGate() {
|
|||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister,
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (q) =>
|
||||||
|
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<AuthGate />
|
<AuthGate />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,11 @@ export default function ServiceDetail() {
|
|||||||
subtitle={s.domain ?? 'Hostnames routed to this service'}
|
subtitle={s.domain ?? 'Hostnames routed to this service'}
|
||||||
onPress={() => router.push(`/service/domains?id=${s.id}`)}
|
onPress={() => router.push(`/service/domains?id=${s.id}`)}
|
||||||
/>
|
/>
|
||||||
|
<Row
|
||||||
|
title="Cron jobs"
|
||||||
|
subtitle="Schedules, run now, history"
|
||||||
|
onPress={() => router.push(`/service/cron?id=${s.id}`)}
|
||||||
|
/>
|
||||||
<Row
|
<Row
|
||||||
title="Scale & sleep"
|
title="Scale & sleep"
|
||||||
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
|
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
ErrorBox,
|
||||||
|
Loading,
|
||||||
|
StatusBadge,
|
||||||
|
} from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceCron() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const jobs = useQuery({
|
||||||
|
queryKey: ['service-cron', id],
|
||||||
|
queryFn: () => api.cronJobs(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
const executions = useQuery({
|
||||||
|
queryKey: ['cron-executions', historyFor],
|
||||||
|
queryFn: () => api.cronExecutions(historyFor!),
|
||||||
|
enabled: !!historyFor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = useMutation({
|
||||||
|
mutationFn: (jobId: string) => api.triggerCronJob(jobId),
|
||||||
|
onSuccess: (_d, jobId) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['service-cron', id] });
|
||||||
|
setHistoryFor(jobId);
|
||||||
|
setTimeout(
|
||||||
|
() => qc.invalidateQueries({ queryKey: ['cron-executions', jobId] }),
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
Alert.alert('Trigger failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (jobs.isLoading) return <Loading />;
|
||||||
|
if (jobs.error) return <ErrorBox error={jobs.error} />;
|
||||||
|
|
||||||
|
const list = jobs.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={jobs.isRefetching}
|
||||||
|
onRefresh={() => jobs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{list.length === 0 ? <Empty text="No cron jobs on this service" /> : null}
|
||||||
|
{list.map((job) => (
|
||||||
|
<Card key={job.id}>
|
||||||
|
<View style={styles.jobHead}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.jobName}>{job.name}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{job.schedule}
|
||||||
|
{job.timezone ? ` · ${job.timezone}` : ''}
|
||||||
|
{job.enabled === false ? ' · disabled' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cmd} numberOfLines={1}>
|
||||||
|
{job.command}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{job.last_run_at ? `last ${relTime(job.last_run_at)}` : 'never run'}
|
||||||
|
{job.next_run_at ? ` · next ${relTime(job.next_run_at)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{job.last_status ? <StatusBadge status={job.last_status} /> : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.jobActions}>
|
||||||
|
<ActionButton
|
||||||
|
label="Run now"
|
||||||
|
kind="primary"
|
||||||
|
disabled={trigger.isPending}
|
||||||
|
onPress={() => trigger.mutate(job.id)}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label={historyFor === job.id ? 'Hide history' : 'History'}
|
||||||
|
onPress={() => setHistoryFor(historyFor === job.id ? null : job.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{historyFor === job.id ? (
|
||||||
|
<View style={styles.history}>
|
||||||
|
{executions.isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : (executions.data ?? []).length === 0 ? (
|
||||||
|
<Text style={styles.meta}>No executions yet</Text>
|
||||||
|
) : (
|
||||||
|
(executions.data ?? []).slice(0, 10).map((ex) => (
|
||||||
|
<View key={ex.id} style={styles.execRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.execTime}>{relTime(ex.started_at)}</Text>
|
||||||
|
{ex.error ? (
|
||||||
|
<Text style={styles.execErr} numberOfLines={2}>
|
||||||
|
{ex.error}
|
||||||
|
</Text>
|
||||||
|
) : ex.output ? (
|
||||||
|
<Text style={styles.execOut} numberOfLines={2}>
|
||||||
|
{ex.output}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={ex.status ?? 'unknown'} />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
jobHead: { flexDirection: 'row', alignItems: 'flex-start' },
|
||||||
|
jobName: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 11, marginTop: 3 },
|
||||||
|
cmd: { color: colors.textDim, fontSize: 12, fontFamily: 'monospace', marginTop: 4 },
|
||||||
|
jobActions: { flexDirection: 'row', gap: 8, marginTop: 10 },
|
||||||
|
history: { marginTop: 10, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 8 },
|
||||||
|
execRow: { flexDirection: 'row', alignItems: 'flex-start', paddingVertical: 6 },
|
||||||
|
execTime: { color: colors.text, fontSize: 12 },
|
||||||
|
execErr: { color: colors.err, fontSize: 11, marginTop: 2 },
|
||||||
|
execOut: { color: colors.textDim, fontSize: 11, marginTop: 2 },
|
||||||
|
});
|
||||||
@@ -3705,6 +3705,50 @@ paths:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: True when the owner re-opened public registration in Settings
|
description: True when the owner re-opened public registration in Settings
|
||||||
|
|
||||||
|
/setup/status:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: First-run setup status
|
||||||
|
description: Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Setup state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
has_users:
|
||||||
|
type: boolean
|
||||||
|
setup_completed:
|
||||||
|
type: boolean
|
||||||
|
needs_setup:
|
||||||
|
type: boolean
|
||||||
|
has_github_app:
|
||||||
|
type: boolean
|
||||||
|
description: GitHub App credentials configured (manifest flow or env)
|
||||||
|
has_tunnel:
|
||||||
|
type: boolean
|
||||||
|
description: Cloudflare tunnel token configured
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
/setup/complete:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: Mark the setup wizard complete
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Setup completed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
|
||||||
/settings:
|
/settings:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Reference in new issue
Block a user