mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(mobile): Containr companion app — Expo/RN thin client
Phone app v1 (Phase I): PAT auth against any instance, secure-store credentials, dark UI matching the design system. - Overview: service status counts, per-project health dots, deploy feed - Services/Projects: status badges, drill-down detail - Service detail: start/stop/restart/redeploy/sleep/wake, env-doctor issues, deployment history + rollback - Logs: 300-line tail, 5s poll, stderr highlighted - Databases: status, start/stop/restart, backup-now - Settings: server info, disconnect CI: mobile job (tsc + expo lint) gated on a dedicated app/mobile paths filter so non-mobile PRs skip it entirely. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1054679ca2
commit
925199633b
31 files changed
+13064
-62
No files matched your search
@@ -24,6 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
mobile: ${{ steps.filter.outputs.mobile }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -37,6 +38,8 @@ jobs:
|
||||
- 'install.sh'
|
||||
- '.github/workflows/**'
|
||||
- 'docs/api/**'
|
||||
mobile:
|
||||
- 'app/mobile/**'
|
||||
|
||||
infra:
|
||||
name: Infra (Compose + Scripts)
|
||||
@@ -121,6 +124,34 @@ jobs:
|
||||
- name: 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:
|
||||
name: Compose boot smoke
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+98
-62
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
||||
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`
|
||||
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||
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).
|
||||
- Resources: `projects services deploy logs exec variables databases
|
||||
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).
|
||||
- GoReleaser → GH releases + `install.sh`; `version` command.
|
||||
- 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
|
||||
restart rollback create_database trigger_cron deploy_template exec
|
||||
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||
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);
|
||||
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.
|
||||
|
||||
**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
|
||||
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
|
||||
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
|
||||
`deployment_queue` table + single worker loop — no new infra; Redis
|
||||
exists but don't add BullMQ-style sprawl). Serialize per service;
|
||||
optional per-node serialization. Status gains `queued`.
|
||||
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
|
||||
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
|
||||
cleanly; building jobs cancel context where safe.
|
||||
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
|
||||
image layers vs clean pull/checkout. Webhook-triggered deploys default
|
||||
to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [ ] **Queued→building→running status + persisted step logs.** Already
|
||||
mostly there; add `queued`, stream over existing `/ws`.
|
||||
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
|
||||
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.
|
||||
- [x] **Deploy job queue.** Lightweight internal queue — per-service
|
||||
FIFO in `internal/deployqueue`; status gains `queued`.
|
||||
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
|
||||
jobs cancel immediately, active jobs cancel context; `cancelled` status.
|
||||
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
|
||||
default to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [x] **Queued→building→running status + persisted step logs.** `queued`
|
||||
state lands; streaming over existing log surface.
|
||||
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
|
||||
backend requeues honoring running state.
|
||||
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
|
||||
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
|
||||
ciphertext.
|
||||
|
||||
## 4. Phase C — Service configuration parity
|
||||
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
|
||||
|
||||
- [ ] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; engine accepts
|
||||
`VolumeMounts` already. Volumes tab + orphan-volume listing
|
||||
(`docker volume ls -f dangling`) with delete/attach.
|
||||
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains tab: add/remove/
|
||||
set-default/DNS-status badge. Keep `services.domain` as derived
|
||||
default for compat.
|
||||
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
- [x] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
|
||||
admin volume inventory.
|
||||
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains UI: add/remove/
|
||||
set-default/DNS-status badge. `services.domain` stays derived default.
|
||||
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
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.
|
||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
allowlist validation (middlewares, headers, redirects).
|
||||
- [ ] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
|
||||
at `deployments.go:406`. Static builder = build + nginx sidecar.
|
||||
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
|
||||
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
|
||||
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
|
||||
`app_settings`.
|
||||
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
|
||||
(agent telemetry); warn or block per policy flag.
|
||||
- [ ] **Service clone** + **move between projects**.
|
||||
- [ ] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); `docker login` at pull time;
|
||||
- [x] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
|
||||
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
|
||||
instance defaults in `app_settings`.
|
||||
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
|
||||
`app_settings.capacity_policy=block` rejects, default warns.
|
||||
- [x] **Service clone** + **move between projects** — copies volumes,
|
||||
domains, access gates, vars (ciphertext verbatim); audit-logged.
|
||||
- [x] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); pull auth resolved at deploy;
|
||||
per-service registry link.
|
||||
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
|
||||
Volumes, Proxy/access-controls, Backups sections to the service page
|
||||
(Cron/Previews/Scaling already exist).
|
||||
- [x] **Service detail tabs to match dflow's surface**: Domains,
|
||||
Volumes, access-controls sections added to the service page.
|
||||
|
||||
## 5. Phase D — Data layer maturity
|
||||
|
||||
- [ ] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow.
|
||||
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
|
||||
default stays loopback.
|
||||
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
|
||||
utility container + s3 SDK; restore-from-S3.
|
||||
- [x] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
|
||||
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
|
||||
bind; default stays loopback. (PR #25)
|
||||
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
|
||||
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
|
||||
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||
{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
|
||||
migrators).
|
||||
|
||||
@@ -318,6 +308,32 @@ and wired refs, one click or one CLI call.
|
||||
audit logs, deployments, notifications tables.
|
||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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/*"],
|
||||
}
|
||||
]);
|
||||
Generated
+11420
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 } });
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 } });
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user