Merge remote-tracking branch 'origin/main' into feat/db-public-port

This commit is contained in:
Tomas Dvorak committed 2026-10-09 05:20:45 +02:00
commit 11525194b0
31 files changed
+13064 -62

No files matched your search

+31
View File
@@ -24,6 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
code: ${{ steps.filter.outputs.code }} code: ${{ steps.filter.outputs.code }}
mobile: ${{ steps.filter.outputs.mobile }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
@@ -37,6 +38,8 @@ jobs:
- 'install.sh' - 'install.sh'
- '.github/workflows/**' - '.github/workflows/**'
- 'docs/api/**' - 'docs/api/**'
mobile:
- 'app/mobile/**'
infra: infra:
name: Infra (Compose + Scripts) name: Infra (Compose + Scripts)
@@ -121,6 +124,34 @@ jobs:
- name: Test - name: Test
run: npm test run: npm test
mobile:
name: Mobile (Expo)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/mobile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: app/mobile/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Lint
run: npx expo lint
smoke: smoke:
name: Compose boot smoke name: Compose boot smoke
runs-on: ubuntu-latest runs-on: ubuntu-latest
+98 -62
View File
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin - `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients. clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it) ### Phase A — Agent surface (do this FIRST; everything after inherits it) ✅ SHIPPED (PRs #14–#18)
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`, - [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes` `name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
(`read`/`write`/`admin`, later resource-scoped), `expires_at`, (`read`/`write`/`admin`, later resource-scoped), `expires_at`,
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored). `last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts `POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
`Bearer cnp_…`; Settings → Access tokens UI with one-time display + `Bearer cnp_…`; Settings → Access tokens UI with one-time display +
copyable CLI/MCP config snippets. copyable CLI/MCP config snippets.
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client: - [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles). - `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases - Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications cron templates nodes scaling ha security gateway notifications
@@ -143,16 +143,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict). 0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command. - GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint. - Conventions from docsync/envdiff/cix/repolint.
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the - [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
CLI: `list_projects create_service deploy get_logs set_variable CLI: `list_projects create_service deploy get_logs set_variable
restart rollback create_database trigger_cron deploy_template exec restart rollback create_database trigger_cron deploy_template exec
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN` get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
or shared `~/.containr/config`. `containr mcp serve` + install snippets or shared `~/.containr/config`. `containr mcp serve` + install snippets
for Devin/Claude/Cursor. for Devin/Claude/Cursor.
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints; - [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
uniform `{error, code, details}` envelope (audit bare-string returns); uniform `{error, code, details}` envelope (audit bare-string returns);
cursor pagination on growing lists; name-or-id lookup everywhere. cursor pagination on growing lists; name-or-id lookup everywhere.
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` + - [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible. `llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a **Gate**: an agent holding only a PAT registers a project, deploys from a
@@ -161,78 +161,68 @@ for a human.
--- ---
## 3. Phase B — Deployment pipeline maturity ## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
dflow's one real architectural edge. Containr deploys are fire-and-forget dflow's one real architectural edge. Containr deploys are fire-and-forget
goroutines; this fixes correctness *and* unlocks the ops surface. goroutines; this fixes correctness *and* unlocks the ops surface.
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres - [x] **Deploy job queue.** Lightweight internal queue — per-service
`deployment_queue` table + single worker loop — no new infra; Redis FIFO in `internal/deployqueue`; status gains `queued`.
exists but don't add BullMQ-style sprawl). Serialize per service; - [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
optional per-node serialization. Status gains `queued`. jobs cancel immediately, active jobs cancel context; `cancelled` status.
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` → - [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel default to no-cache (dflow convention — prevents stale-layer surprises).
cleanly; building jobs cancel context where safe. - [x] **Queued→building→running status + persisted step logs.** `queued`
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing state lands; streaming over existing log surface.
image layers vs clean pull/checkout. Webhook-triggered deploys default - [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
to no-cache (dflow convention — prevents stale-layer surprises). backend requeues honoring running state.
- [ ] **Queued→building→running status + persisted step logs.** Already - [x] **Encrypt variables at rest.** `is_secret` values encrypted
mostly there; add `queued`, stream over existing `/ws`. AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to ciphertext.
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
running state. Currently rows update but containers keep stale env —
silent-drift bug class, not just UX.
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
plaintext today (verified). Envelope-encrypt `is_secret` values
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
encrypts every var, we encrypt secrets — migration = transparent
encrypt-on-write + decrypt-on-read.
## 4. Phase C — Service configuration parity ## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
- [ ] **Volumes on services** — `services.volumes` jsonb - [x] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; engine accepts `[{volume|host_path, container_path, read_only}]`; Volumes tab +
`VolumeMounts` already. Volumes tab + orphan-volume listing admin volume inventory.
(`docker volume ls -f dangling`) with delete/attach. - [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default, cert_type, status, last_checked_at)`. Domains UI: add/remove/
cert_type, status, last_checked_at)`. Domains tab: add/remove/ set-default/DNS-status badge. `services.domain` stays derived default.
set-default/DNS-status badge. Keep `services.domain` as derived - [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
default for compat.
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
compares vs edge IP → `ok|wrong-target|pending`. compares vs edge IP → `ok|wrong-target|pending`.
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels; - [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed. no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with - [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects). allowlist validation (middlewares, headers, redirects).
- [ ] **Builder override** — `services.builder` enum - [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks` `auto|railpack|nixpacks|dockerfile|static`; static builder = build +
at `deployments.go:406`. Static builder = build + nginx sidecar. nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units - [x] **Resource limits + reserves** — `cpu`/`memory` caps +
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` → `cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in instance defaults in `app_settings`.
`app_settings`. - [x] **Pre-deploy capacity check** — reservations vs node capacity;
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity `app_settings.capacity_policy=block` rejects, default warns.
(agent telemetry); warn or block per policy flag. - [x] **Service clone** + **move between projects** — copies volumes,
- [ ] **Service clone** + **move between projects**. domains, access gates, vars (ciphertext verbatim); audit-logged.
- [ ] **Private registries** — `registry_credentials` - [x] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); `docker login` at pull time; (server/username/secret, encrypted); pull auth resolved at deploy;
per-service registry link. per-service registry link.
- [ ] **Service detail tabs to match dflow's surface**: add Domains, - [x] **Service detail tabs to match dflow's surface**: Domains,
Volumes, Proxy/access-controls, Backups sections to the service page Volumes, access-controls sections added to the service page.
(Cron/Previews/Scaling already exist).
## 5. Phase D — Data layer maturity ## 5. Phase D — Data layer maturity
- [ ] **External DB registration** — `provider='managed'|'external'`, - [x] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow. conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port; - [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
default stays loopback. bind; default stays loopback. (PR #25)
- [ ] **Offsite backups** — `backup_targets` (S3-compatible: - [x] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; ship archives via endpoint/bucket/keys encrypted); per-DB destination; archives shipped
utility container + s3 SDK; restore-from-S3. via holder container + minio-go; restore-from-S3 fallback. (PR #26)
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate - [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
{target_node_id}`: dump → stream transfer → provision → restore → {target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Needs ≥2 nodes or local↔node. re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with - [ ] **Backup import** — upload archive → new restore point (pairs with
migrators). migrators).
@@ -318,6 +308,32 @@ and wired refs, one click or one CLI call.
audit logs, deployments, notifications tables. audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline, - [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings. upgrade available, security findings.
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload
removed, status `sleeping`. Wake-on-traffic via per-service
placeholder container on the edge network carrying the same Traefik
host rule at higher priority — serves a waking page, calls
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
Verified `running → sleeping → waking → running` end to end.
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
unresolved `${{…}}` refs, empty values, and unreadable
(rotated-key) secrets; template `plan` endpoint reports missing
required vars before deploy (PR #30). Frontend Variables section
shows the banner.
- [x] **Shared project variables** — `project_variables` table,
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
pulls images, runs migrations, prints admin URL. GHCR images mean
installs never build from source.
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
docs-only PRs skip heavyweight jobs, and release on main/tag:
conventional-commit version bump → tag → GHCR images (backend,
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
→ GitHub Release → Trivy scans (PR #33).
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed - [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now. forward-compatible now.
@@ -335,6 +351,26 @@ and wired refs, one click or one CLI call.
fetch/publish; bundled catalog works offline (dflow's self-contained fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home). principle — nothing phones home).
## 9b. Phase I — Companion surfaces
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
over the agent-shaped API; dark UI mirroring the design system. PAT
auth against any instance URL, stored in platform secure storage.
- **Dashboard** — service status counts (running/sleeping/failed/
stopped), per-project service health dots, recent deployments feed.
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica
scaling, cron execution history** — next iteration.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
banner.
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
the instance is unreachable (cached last-known state, clear banner).
## 10. Carried-over debt (ROADMAP.md items still open) ## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit → - [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+40
View File
@@ -0,0 +1,40 @@
This is an Expo/React Native mobile application — the Containr companion client. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
## Expo has changed — do not trust your training data
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
1. Read the major version of the `expo` package in `package.json`.
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
## Commands
```bash
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
npx expo start # start the dev server
npx expo lint # lint
npx tsc --noEmit # typecheck
npx expo-doctor # diagnose dependency and config issues
npx expo install --fix # fix incompatible package versions
```
Run lint and typecheck before declaring any task done.
## Navigation & Routing
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
- Docs: https://docs.expo.dev/router/introduction.md
## Building with EAS
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `npx eas-cli@latest <command>`.
Docs: https://docs.expo.dev/eas/index.md
## Rules
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. They are gitignored.
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
- Prefer recommended Expo modules over third-party libraries. Docs: https://docs.expo.dev/versions/latest/index.md
- The app speaks only to the Containr REST API (`/api/v1`) with a `cnp_` personal access token — same contract as the CLI and MCP server. Add endpoints there, never to the mobile client alone.
+33
View File
@@ -0,0 +1,33 @@
{
"expo": {
"name": "Containr",
"slug": "containr-mobile",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "containr",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "ai.containr.mobile"
},
"android": {
"package": "ai.containr.mobile",
"adaptiveIcon": {
"backgroundColor": "#0B0F14",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-font"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+10
View File
@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);
+11420
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "containr-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@tanstack/react-query": "^5.104.1",
"expo": "~57.0.27",
"expo-constants": "~57.0.21",
"expo-font": "~57.0.4",
"expo-linking": "~57.0.12",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
},
"devDependencies": {
"@types/react": "~19.2.2",
"eslint": "^9.39.5",
"eslint-config-expo": "~57.0.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"private": true
}
+185
View File
@@ -0,0 +1,185 @@
export interface Project {
id: string;
name: string;
description?: string;
created_at?: string;
}
export interface Service {
id: string;
project_id: string;
name: string;
type: string;
status: string;
image?: string;
environment?: string;
domain?: string;
public_url?: string;
port?: number;
replicas?: number;
cpu?: string;
memory?: string;
sleep_enabled?: boolean;
sleep_idle_minutes?: number;
created_at?: string;
updated_at?: string;
}
export interface Deployment {
id: string;
service_id: string;
service_name?: string;
project_name?: string;
status: string;
image_name?: string;
created_at?: string;
completed_at?: string | null;
}
export interface Database {
id: string;
name: string;
type: string;
status: string;
version?: string;
plan?: string;
connection_url?: string;
created_at?: string;
}
export interface EnvCheck {
ok: boolean;
unresolved: string[];
empty: string[];
unreadable: string[];
}
export interface LogEntry {
timestamp: string;
message: string;
stream: string;
}
export interface Template {
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export class Api {
constructor(
private baseUrl: string,
private token: string,
) {}
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j.error) msg = j.error;
} catch {
// non-JSON error body
}
throw new ApiError(res.status, msg);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
ping() {
return fetch(`${this.baseUrl}/health`).then((r) => r.ok);
}
projects() {
return this.req<{ projects: Project[] }>('GET', '/projects?limit=100').then(
(r) => r.projects ?? [],
);
}
projectServices(projectId: string) {
return this.req<{ services: Service[] }>(
'GET',
`/projects/${projectId}/services?limit=200`,
).then((r) => r.services ?? []);
}
service(id: string) {
return this.req<{ service: Service }>('GET', `/services/${id}`).then((r) => r.service);
}
serviceAction(id: string, action: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') {
return this.req<{ status?: string; message?: string }>(
'POST',
`/services/${id}/${action}`,
);
}
serviceLogs(id: string, tail = 300) {
return this.req<{ logs: LogEntry[]; message?: string }>(
'GET',
`/services/${id}/logs?tail=${tail}`,
).then((r) => r.logs ?? []);
}
envCheck(id: string) {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
`/services/${id}/deployments?limit=20`,
).then((r) => r.deployments ?? []);
}
recentDeployments() {
return this.req<{ deployments: Deployment[] }>(
'GET',
'/deployments?limit=20',
).then((r) => r.deployments ?? []);
}
deploymentAction(id: string, action: 'rollback' | 'cancel') {
return this.req<unknown>('POST', `/deployments/${id}/${action}`);
}
databases() {
return this.req<{ databases: Database[] }>('GET', '/databases').then(
(r) => r.databases ?? [],
);
}
databaseBackup(id: string) {
return this.req<unknown>('POST', `/databases/${id}/backup`);
}
databaseAction(id: string, action: 'start' | 'stop' | 'restart') {
return this.req<unknown>('POST', `/databases/${id}/action`, { action });
}
templates() {
return this.req<{ templates: Template[] }>('GET', '/templates?limit=100').then(
(r) => r.templates ?? [],
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../theme';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text,
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textDim,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Overview',
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="services"
options={{
title: 'Services',
tabBarIcon: ({ color, size }) => (
<Ionicons name="cube-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="databases"
options={{
title: 'Databases',
tabBarIcon: ({ color, size }) => (
<Ionicons name="server-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
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 Databases() {
const api = useApi();
const qc = useQueryClient();
const dbs = useQuery({ queryKey: ['databases'], queryFn: () => api.databases() });
const action = useMutation({
mutationFn: ({ id, act }: { id: string; act: 'start' | 'stop' | 'restart' }) =>
api.databaseAction(id, act),
onSuccess: () => qc.invalidateQueries({ queryKey: ['databases'] }),
onError: (e) => Alert.alert('Action failed', e instanceof Error ? e.message : String(e)),
});
const backup = useMutation({
mutationFn: (id: string) => api.databaseBackup(id),
onSuccess: () => Alert.alert('Backup started', 'Snapshot queued for this database.'),
onError: (e) => Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)),
});
if (dbs.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={dbs.data ?? []}
keyExtractor={(d) => d.id}
refreshControl={
<RefreshControl
refreshing={dbs.isRefetching}
onRefresh={() => dbs.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
dbs.error ? <ErrorBox error={dbs.error} /> : <Empty text="No databases" />
}
renderItem={({ item }) => (
<Card>
<View style={styles.head}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.meta}>
{item.type}
{item.version ? ` ${item.version}` : ''} · created {relTime(item.created_at)}
</Text>
</View>
<StatusBadge status={item.status} />
</View>
<View style={styles.actions}>
{item.status === 'running' ? (
<ActionButton
label="Backup now"
kind="primary"
disabled={backup.isPending}
onPress={() => backup.mutate(item.id)}
/>
) : (
<ActionButton
label="Start"
kind="primary"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'start' })}
/>
)}
{item.status === 'running' ? (
<>
<ActionButton
label="Restart"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'restart' })}
/>
<ActionButton
label="Stop"
kind="danger"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'stop' })}
/>
</>
) : null}
</View>
</Card>
)}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
name: { color: colors.text, fontSize: 16, fontWeight: '600' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 2 },
actions: { flexDirection: 'row', flexWrap: 'wrap' },
});
+122
View File
@@ -0,0 +1,122 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { Card, Empty, ErrorBox, Row, SectionTitle, StatusBadge, StatusDot } from '../../components';
export default function Overview() {
const api = useApi();
const router = useRouter();
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
const deployments = useQuery({
queryKey: ['deployments'],
queryFn: () => api.recentDeployments(),
});
const servicesQueries = useQueries({
queries: (projects.data ?? []).map((p) => ({
queryKey: ['services', p.id],
queryFn: () => api.projectServices(p.id),
})),
});
const allServices = servicesQueries.flatMap((q) => q.data ?? []);
const counts: Record<string, number> = {};
for (const s of allServices) counts[s.status] = (counts[s.status] ?? 0) + 1;
const refetch = () => {
projects.refetch();
deployments.refetch();
servicesQueries.forEach((q) => q.refetch());
};
const refreshing = projects.isRefetching || deployments.isRefetching;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor={colors.accent} />
}
ListHeaderComponent={
<>
{projects.error ? <ErrorBox error={projects.error} /> : null}
<View style={styles.statRow}>
<Stat label="Running" value={counts.running ?? 0} color={colors.ok} />
<Stat label="Sleeping" value={counts.sleeping ?? 0} color={colors.info} />
<Stat label="Failed" value={counts.failed ?? 0} color={colors.err} />
<Stat label="Stopped" value={counts.stopped ?? 0} color={colors.textDim} />
</View>
<SectionTitle>Projects</SectionTitle>
{projects.data?.length === 0 ? <Empty text="No projects yet" /> : null}
</>
}
data={projects.data ?? []}
keyExtractor={(p) => p.id}
renderItem={({ item }) => {
const idx = (projects.data ?? []).findIndex((p) => p.id === item.id);
const svcs = servicesQueries[idx]?.data ?? [];
return (
<Card>
<Row
title={item.name}
subtitle={`${svcs.length} service${svcs.length === 1 ? '' : 's'}`}
onPress={() => router.push(`/project/${item.id}?name=${encodeURIComponent(item.name)}`)}
right={
<View style={styles.dots}>
{svcs.slice(0, 6).map((s) => (
<StatusDot key={s.id} status={s.status} />
))}
</View>
}
/>
</Card>
);
}}
ListFooterComponent={
<>
<SectionTitle>Recent deployments</SectionTitle>
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
{(deployments.data ?? []).slice(0, 10).map((d) => (
<Row
key={d.id}
title={`${d.service_name ?? d.service_id}`}
subtitle={`${d.project_name ?? ''} · ${relTime(d.created_at)}`}
right={<StatusBadge status={d.status} />}
onPress={() => router.push(`/service/${d.service_id}`)}
/>
))}
</>
}
/>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={[styles.stat, { borderColor: color }]}>
<Text style={[styles.statValue, { color }]}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
statRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
stat: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 10,
borderWidth: 1,
paddingVertical: 12,
alignItems: 'center',
},
statValue: { fontSize: 20, fontWeight: '800' },
statLabel: { color: colors.textDim, fontSize: 11, marginTop: 2 },
dots: { flexDirection: 'row', gap: 4 },
});
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
export default function Services() {
const api = useApi();
const router = useRouter();
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
const servicesQueries = useQueries({
queries: (projects.data ?? []).map((p) => ({
queryKey: ['services', p.id],
queryFn: () => api.projectServices(p.id),
})),
});
const rows = servicesQueries.flatMap((q, i) =>
(q.data ?? []).map((s) => ({ ...s, _project: projects.data?.[i]?.name ?? '' })),
);
if (projects.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={rows}
keyExtractor={(s) => s.id}
refreshControl={
<RefreshControl
refreshing={servicesQueries.some((q) => q.isRefetching)}
onRefresh={() => servicesQueries.forEach((q) => q.refetch())}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
projects.error ? <ErrorBox error={projects.error} /> : <Empty text="No services" />
}
renderItem={({ item }) => (
<Row
title={item.name}
subtitle={`${item._project} · ${item.type}${item.domain ? ` · ${item.domain}` : ''}`}
right={<StatusBadge status={item.status} />}
onPress={() => router.push(`/service/${item.id}`)}
/>
)}
/>
);
}
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Alert, ScrollView, StyleSheet, Text } from 'react-native';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { useSession } from '../../session';
import { colors } from '../../theme';
import { ActionButton, Card, SectionTitle } from '../../components';
export default function Settings() {
const { serverUrl, signOut } = useSession();
const router = useRouter();
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<SectionTitle>Connection</SectionTitle>
<Card>
<Text style={styles.label}>Server</Text>
<Text style={styles.value}>{serverUrl}</Text>
</Card>
<SectionTitle>App</SectionTitle>
<Card>
<Text style={styles.label}>Version</Text>
<Text style={styles.value}>
{Constants.expoConfig?.version ?? 'dev'} · Expo SDK{' '}
{Constants.expoConfig?.sdkVersion ?? '?'}
</Text>
</Card>
<ActionButton
label="Disconnect"
kind="danger"
onPress={() =>
Alert.alert('Disconnect', 'Remove the stored server and token?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/login');
},
},
])
}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
label: { color: colors.textDim, fontSize: 12, marginBottom: 4 },
value: { color: colors.text, fontSize: 15 },
});
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StatusBar } from 'expo-status-bar';
import { SessionProvider, useSession } from '../session';
import { colors } from '../theme';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 15_000, retry: 1 },
},
});
function AuthGate() {
const { ready, api } = useSession();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (!ready) return;
const inLogin = segments[0] === 'login';
if (!api && !inLogin) router.replace('/login');
else if (api && inLogin) router.replace('/');
}, [ready, api, segments, router]);
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack>
);
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<StatusBar style="light" />
<AuthGate />
</SessionProvider>
</QueryClientProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from 'expo-router';
export default function Index() {
return <Redirect href="/(tabs)" />;
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
} from 'react-native';
import { useSession, normalizeServerUrl } from '../session';
import { colors } from '../theme';
export default function Login() {
const { signIn } = useSession();
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
setBusy(true);
setError(null);
try {
const normalized = normalizeServerUrl(url);
const res = await fetch(`${normalized}/api/v1/projects?limit=1`, {
headers: { Authorization: `Bearer ${token.trim()}` },
});
if (res.status === 401 || res.status === 403) {
throw new Error('Token rejected. Use a personal access token (cnp_…).');
}
if (!res.ok) throw new Error(`Server responded ${res.status}`);
await signIn(normalized, token);
} catch (e) {
setError(e instanceof Error ? e.message : 'Connection failed');
} finally {
setBusy(false);
}
};
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.logo}>Containr</Text>
<Text style={styles.sub}>Self-hosted platform console</Text>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
placeholder="https://containr.example.com"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
value={url}
onChangeText={setUrl}
/>
<Text style={styles.label}>Personal access token</Text>
<TextInput
style={styles.input}
placeholder="cnp_…"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
value={token}
onChangeText={setToken}
/>
{error ? <Text style={styles.err}>{error}</Text> : null}
<TouchableOpacity
style={[styles.btn, (!url.trim() || !token.trim() || busy) && { opacity: 0.5 }]}
onPress={connect}
disabled={!url.trim() || !token.trim() || busy}
>
{busy ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.btnText}>Connect</Text>
)}
</TouchableOpacity>
<Text style={styles.hint}>
Create a token in the web UI under Settings → Access Tokens.
</Text>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg, padding: 24, justifyContent: 'center' },
logo: { color: colors.text, fontSize: 34, fontWeight: '800', letterSpacing: -0.5 },
sub: { color: colors.textDim, fontSize: 14, marginBottom: 32, marginTop: 4 },
label: {
color: colors.textDim,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 6,
},
input: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
color: colors.text,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
marginBottom: 16,
},
err: { color: colors.err, marginBottom: 12 },
btn: {
backgroundColor: colors.accent,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
},
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
hint: { color: colors.textDim, fontSize: 12, marginTop: 20, textAlign: 'center' },
});
+57
View File
@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
export default function ProjectDetail() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const router = useRouter();
const nav = useNavigation();
useEffect(() => {
if (name) nav.setOptions({ title: name });
}, [name, nav]);
const services = useQuery({
queryKey: ['services', id],
queryFn: () => api.projectServices(id!),
enabled: !!id,
});
if (services.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={services.data ?? []}
keyExtractor={(s) => s.id}
refreshControl={
<RefreshControl
refreshing={services.isRefetching}
onRefresh={() => services.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
services.error ? <ErrorBox error={services.error} /> : <Empty text="No services" />
}
renderItem={({ item }) => (
<Row
title={item.name}
subtitle={`${item.type} · ${item.environment ?? 'production'}${
item.domain ? ` · ${item.domain}` : ''
}`}
right={<StatusBadge status={item.status} />}
onPress={() => router.push(`/service/${item.id}`)}
/>
)}
/>
);
}
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
+191
View File
@@ -0,0 +1,191 @@
import React from 'react';
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams, useRouter } 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,
SectionTitle,
StatusBadge,
} from '../../components';
export default function ServiceDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const router = useRouter();
const qc = useQueryClient();
const service = useQuery({
queryKey: ['service', id],
queryFn: () => api.service(id!),
enabled: !!id,
refetchInterval: 10_000,
});
const envCheck = useQuery({
queryKey: ['env-check', id],
queryFn: () => api.envCheck(id!),
enabled: !!id,
retry: false,
});
const deployments = useQuery({
queryKey: ['service-deployments', id],
queryFn: () => api.serviceDeployments(id!),
enabled: !!id,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['service', id] });
qc.invalidateQueries({ queryKey: ['services'] });
qc.invalidateQueries({ queryKey: ['service-deployments', id] });
};
const onErr = (e: unknown) =>
Alert.alert('Action failed', e instanceof Error ? e.message : String(e));
const action = useMutation({
mutationFn: (act: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') =>
api.serviceAction(id!, act),
onSuccess: invalidate,
onError: onErr,
});
const rollback = useMutation({
mutationFn: (depId: string) => api.deploymentAction(depId, 'rollback'),
onSuccess: invalidate,
onError: onErr,
});
const s = service.data;
if (service.isLoading) return <Loading />;
if (!s) return <ErrorBox error={service.error ?? new Error('Not found')} />;
const busy = action.isPending;
const canStart = s.status === 'stopped' || s.status === 'failed';
const canStop = s.status === 'running';
const canWake = s.status === 'sleeping';
const lastDeploy = deployments.data?.[0];
return (
<ScrollView
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={service.isRefetching}
onRefresh={() => service.refetch()}
tintColor={colors.accent}
/>
}
>
<View style={styles.head}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{s.name}</Text>
<Text style={styles.meta}>
{s.type} · {s.environment ?? 'production'}
{s.image ? `\n${s.image}` : ''}
</Text>
</View>
<StatusBadge status={s.status} />
</View>
{envCheck.data && !envCheck.data.ok ? (
<Card style={{ borderColor: colors.warn }}>
<Text style={styles.warnTitle}>Environment issues</Text>
{envCheck.data.unresolved.map((v) => (
<Text key={v} style={styles.warnItem}>
Unresolved reference: {v}
</Text>
))}
{envCheck.data.empty.map((v) => (
<Text key={v} style={styles.warnItem}>
Empty value: {v}
</Text>
))}
{envCheck.data.unreadable.map((v) => (
<Text key={v} style={styles.warnItem}>
Unreadable secret (rotate): {v}
</Text>
))}
</Card>
) : null}
<SectionTitle>Actions</SectionTitle>
<View style={styles.actions}>
{canStart ? (
<ActionButton label="Start" kind="primary" disabled={busy} onPress={() => action.mutate('start')} />
) : null}
{canStop ? (
<ActionButton label="Stop" kind="danger" disabled={busy} onPress={() => action.mutate('stop')} />
) : null}
{canWake ? (
<ActionButton label="Wake" kind="primary" disabled={busy} onPress={() => action.mutate('wake')} />
) : null}
{s.status === 'running' ? (
<>
<ActionButton label="Restart" disabled={busy} onPress={() => action.mutate('restart')} />
<ActionButton label="Redeploy" disabled={busy} onPress={() => action.mutate('redeploy')} />
{s.sleep_enabled ? (
<ActionButton label="Sleep" disabled={busy} onPress={() => action.mutate('sleep')} />
) : null}
</>
) : null}
</View>
<View style={styles.actions}>
<ActionButton
label="View logs"
onPress={() => router.push(`/service/logs?id=${s.id}&name=${encodeURIComponent(s.name)}`)}
/>
</View>
{s.sleep_enabled ? (
<Text style={styles.hint}>
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
</Text>
) : null}
<SectionTitle>Deployments</SectionTitle>
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
{(deployments.data ?? []).slice(0, 8).map((d) => (
<Card key={d.id}>
<View style={styles.depRow}>
<View style={{ flex: 1 }}>
<Text style={styles.depTitle}>{d.image_name ?? d.id.slice(0, 8)}</Text>
<Text style={styles.meta}>{relTime(d.created_at)}</Text>
</View>
<StatusBadge status={d.status} />
</View>
{d.id !== lastDeploy?.id && d.status === 'success' ? (
<ActionButton
label="Rollback to this"
disabled={rollback.isPending}
onPress={() =>
Alert.alert('Rollback', 'Roll back to this deployment?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Rollback', onPress: () => rollback.mutate(d.id) },
])
}
/>
) : null}
</Card>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
name: { color: colors.text, fontSize: 24, fontWeight: '800' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 4 },
warnTitle: { color: colors.warn, fontWeight: '700', marginBottom: 6 },
warnItem: { color: colors.text, fontSize: 13, marginTop: 2 },
actions: { flexDirection: 'row', flexWrap: 'wrap' },
hint: { color: colors.textDim, fontSize: 12, marginTop: 4 },
depRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
depTitle: { color: colors.text, fontSize: 14, fontWeight: '600' },
});
+71
View File
@@ -0,0 +1,71 @@
import React, { useEffect } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text } from 'react-native';
import { useLocalSearchParams, useNavigation } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading } from '../../components';
export default function ServiceLogs() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const nav = useNavigation();
useEffect(() => {
if (name) nav.setOptions({ title: `${name} logs` });
}, [name, nav]);
const logs = useQuery({
queryKey: ['logs', id],
queryFn: () => api.serviceLogs(id!, 300),
enabled: !!id,
refetchInterval: 5000,
});
if (logs.isLoading) return <Loading />;
const entries = logs.data ?? [];
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 12 }}
data={[...entries].reverse()}
inverted
keyExtractor={(_, i) => String(i)}
refreshControl={
<RefreshControl
refreshing={logs.isRefetching}
onRefresh={() => logs.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
logs.error ? (
<ErrorBox error={logs.error} />
) : (
<Empty text="No log output yet" />
)
}
renderItem={({ item }) => (
<Text
style={[
styles.line,
item.stream === 'stderr' && { color: colors.err },
]}
>
{item.message}
</Text>
)}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
line: {
color: colors.text,
fontFamily: 'monospace',
fontSize: 11,
paddingVertical: 1,
},
});
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { colors, statusColor } from './theme';
export function StatusDot({ status }: { status: string }) {
return (
<View
style={[styles.dot, { backgroundColor: statusColor[status] ?? colors.textDim }]}
/>
);
}
export function StatusBadge({ status }: { status: string }) {
const c = statusColor[status] ?? colors.textDim;
return (
<View style={[styles.badge, { borderColor: c }]}>
<View style={[styles.dot, { backgroundColor: c }]} />
<Text style={[styles.badgeText, { color: c }]}>{status}</Text>
</View>
);
}
export function Card({ children, style }: { children: React.ReactNode; style?: object }) {
return <View style={[styles.card, style]}>{children}</View>;
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <Text style={styles.section}>{children}</Text>;
}
export function Empty({ text }: { text: string }) {
return <Text style={styles.empty}>{text}</Text>;
}
export function ErrorBox({ error }: { error: unknown }) {
const msg = error instanceof Error ? error.message : String(error);
return <Text style={styles.err}>{msg}</Text>;
}
export function ActionButton({
label,
onPress,
kind = 'default',
disabled,
}: {
label: string;
onPress: () => void;
kind?: 'default' | 'primary' | 'danger';
disabled?: boolean;
}) {
const bg =
kind === 'primary' ? colors.accent : kind === 'danger' ? colors.err : colors.surfaceAlt;
return (
<TouchableOpacity
style={[styles.btn, { backgroundColor: bg, opacity: disabled ? 0.4 : 1 }]}
onPress={onPress}
disabled={disabled}
>
<Text style={styles.btnText}>{label}</Text>
</TouchableOpacity>
);
}
export function Row({
title,
subtitle,
right,
onPress,
}: {
title: string;
subtitle?: string;
right?: React.ReactNode;
onPress?: () => void;
}) {
return (
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress}>
<View style={{ flex: 1, marginRight: 8 }}>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
{subtitle ? (
<Text style={styles.rowSub} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{right}
</TouchableOpacity>
);
}
export function Loading() {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} size="large" />
</View>
);
}
const styles = StyleSheet.create({
dot: { width: 8, height: 8, borderRadius: 4 },
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 3,
},
badgeText: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' },
card: {
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
padding: 14,
marginBottom: 10,
},
section: {
color: colors.textDim,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
marginTop: 14,
marginBottom: 8,
},
empty: { color: colors.textDim, fontSize: 14, paddingVertical: 12 },
err: { color: colors.err, fontSize: 13, paddingVertical: 8 },
btn: {
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 14,
alignItems: 'center',
marginRight: 8,
marginBottom: 8,
},
btnText: { color: colors.text, fontWeight: '600', fontSize: 13 },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
rowSub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Api } from './api';
const KEY_URL = 'containr.server_url';
const KEY_TOKEN = 'containr.token';
interface Session {
ready: boolean;
api: Api | null;
serverUrl: string | null;
signIn: (serverUrl: string, token: string) => Promise<void>;
signOut: () => Promise<void>;
}
const Ctx = createContext<Session>({
ready: false,
api: null,
serverUrl: null,
signIn: async () => {},
signOut: async () => {},
});
export function normalizeServerUrl(raw: string): string {
let u = raw.trim();
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
return u.replace(/\/+$/, '');
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [serverUrl, setServerUrl] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const [u, t] = await Promise.all([
SecureStore.getItemAsync(KEY_URL),
SecureStore.getItemAsync(KEY_TOKEN),
]);
setServerUrl(u);
setToken(t);
} finally {
setReady(true);
}
})();
}, []);
const value = useMemo<Session>(
() => ({
ready,
serverUrl,
api: serverUrl && token ? new Api(serverUrl, token) : null,
signIn: async (url, tok) => {
const normalized = normalizeServerUrl(url);
await SecureStore.setItemAsync(KEY_URL, normalized);
await SecureStore.setItemAsync(KEY_TOKEN, tok.trim());
setServerUrl(normalized);
setToken(tok.trim());
},
signOut: async () => {
await SecureStore.deleteItemAsync(KEY_URL);
await SecureStore.deleteItemAsync(KEY_TOKEN);
setServerUrl(null);
setToken(null);
},
}),
[ready, serverUrl, token],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSession() {
return useContext(Ctx);
}
export function useApi(): Api {
const { api } = useSession();
if (!api) throw new Error('not signed in');
return api;
}
+30
View File
@@ -0,0 +1,30 @@
export const colors = {
bg: '#0B0F14',
surface: '#131A22',
surfaceAlt: '#1A232E',
border: '#26313D',
text: '#E8EEF4',
textDim: '#8A97A5',
accent: '#4F8FF7',
accentDim: '#2A4A7F',
ok: '#3FB68B',
warn: '#E0A93E',
err: '#E05C5C',
info: '#4FB7D9',
};
export const statusColor: Record<string, string> = {
running: colors.ok,
deployed: colors.ok,
success: colors.ok,
building: colors.warn,
deploying: colors.warn,
pending: colors.warn,
waking: colors.warn,
sleeping: colors.info,
stopped: colors.textDim,
queued: colors.textDim,
failed: colors.err,
error: colors.err,
cancelled: colors.textDim,
};
+14
View File
@@ -0,0 +1,14 @@
export function relTime(iso?: string | null): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '—';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}